combobox

PreviousNext
Docs
takiui

Preview

Loading preview…
registry/new-york/ui/combobox.tsx
"use client"

import React from "react"
import { ChevronDown } from "lucide-react"
import {
  ComboBox as AriaComboBox,
  ComboBoxProps as AriaComboBoxProps,
  Button,
  composeRenderProps,
  Input,
  ListBox,
  ListBoxItemProps,
  ValidationResult,
} from "react-aria-components"
import { tv } from "tailwind-variants"

import { cn } from "../lib/utils"
import { FieldDescription, FieldError, FieldLabel } from "./field"
import { DropdownItem, DropdownSection, DropdownSectionProps } from "./list-box"
import { Popover } from "./popover"

const comboBoxStyles = tv({
  base: "group relative flex flex-col gap-1",
})

const inputWrapperStyles = tv({
  base: "border-input  [&_svg:not([class*='text-'])]:text-muted-foreground focus-within:border-ring focus-within:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center gap-2 rounded-md border bg-transparent pr-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-within:ring-[3px] has-[[data-disabled]]:cursor-not-allowed has-[[data-disabled]]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  variants: {
    size: {
      default: "h-9",
      sm: "h-8",
    },
  },
  defaultVariants: {
    size: "default",
  },
})

const inputStyles = tv({
  base: "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex-1 bg-transparent px-3 py-1 text-base outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed md:text-sm",
})

const buttonStyles = tv({
  base: "flex items-center justify-center outline-none transition-colors",
})

export interface ComboBoxProps<T extends object>
  extends Omit<AriaComboBoxProps<T>, "children"> {
  label?: string
  description?: string
  errorMessage?: string | ((validation: ValidationResult) => string)
  placeholder?: string
  items?: Iterable<T>
  children: React.ReactNode | ((item: T) => React.ReactNode)
  size?: "default" | "sm"
}

export function ComboBox<T extends object>({
  label,
  description,
  errorMessage,
  placeholder,
  children,
  items,
  size = "default",
  ...props
}: ComboBoxProps<T>) {
  return (
    <AriaComboBox
      {...props}
      className={composeRenderProps(props.className, (className) =>
        cn(comboBoxStyles(), className)
      )}
    >
      {label && <FieldLabel>{label}</FieldLabel>}
      <div className={inputWrapperStyles({ size })}>
        <Input placeholder={placeholder} className={inputStyles()} />
        <Button className={buttonStyles()}>
          <ChevronDown aria-hidden />
        </Button>
      </div>
      {description && <FieldDescription>{description}</FieldDescription>}
      <FieldError>{errorMessage}</FieldError>
      <Popover className="w-56 min-w-(--trigger-width)">
        <ListBox
          items={items}
          className="max-h-[inherit] overflow-auto p-1 outline-hidden [clip-path:inset(0_0_0_0_round_.75rem)]"
        >
          {children}
        </ListBox>
      </Popover>
    </AriaComboBox>
  )
}

export function ComboBoxItem(props: ListBoxItemProps) {
  return <DropdownItem {...props} />
}

export function ComboBoxSection<T extends object>(
  props: DropdownSectionProps<T>
) {
  return <DropdownSection {...props} />
}

Installation

npx shadcn@latest add @taki/combobox

Usage

import { Combobox } from "@/components/ui/combobox"
<Combobox />