Files
HMDesign/packages/ui/src/components/ui/combobox.tsx
T

135 lines
4.3 KiB
TypeScript

"use client"
import * as React from "react"
import { cn } from "cn"
import { CheckIcon, ChevronDownIcon } from "lucide-react"
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "./command"
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
import { useConnectedPopupRef } from "./use-connected-popup-ref"
// A searchable single select composed from the public Popover and Command (not Select, not a copy of its list):
// <Popover><PopoverTrigger = field-like button role="combobox"/><PopoverContent><Command>Input + List</Command></PopoverContent></Popover>
// Select stays for short lists that need no search (Radix Select). Combobox is for search / filter. Filtering, keyboard and the
// listbox ARIA are cmdk's; Popover keeps Portal, collision, Escape and focus return to the trigger.
// The typed `options` wrapper is a minimal API (value, label, disabled); anything richer is composed with Command directly.
type ComboboxOption = {
value: string
label: string
disabled?: boolean
}
type ComboboxSize = "medium" | "large"
type ComboboxProps = Omit<
React.ComponentProps<"button">,
"value" | "defaultValue" | "onChange" | "children"
> & {
options: ComboboxOption[]
value?: string
defaultValue?: string
/** Explicit value ownership. Omitted: infer from value !== undefined. Keep this mode stable while mounted. */
controlled?: boolean
onValueChange?: (value: string) => void
placeholder?: string
searchPlaceholder?: string
emptyText?: string
size?: ComboboxSize
}
function Combobox({
className,
options,
value: valueProp,
defaultValue,
controlled: controlledProp,
onValueChange,
placeholder = "선택",
searchPlaceholder = "검색...",
emptyText = "결과가 없습니다.",
size = "large",
disabled,
...props
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const [inner, setInner] = React.useState<string | undefined>(defaultValue)
const controlled = controlledProp ?? (valueProp !== undefined)
const value = controlled ? valueProp : inner
const selected = options.find((option) => option.value === value)
const popupRef = useConnectedPopupRef()
const select = (next: string) => {
if (!controlled) setInner(next)
onValueChange?.(next)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
data-slot="combobox-trigger"
data-size={size}
data-empty={selected ? undefined : ""}
disabled={disabled}
className={cn("flex w-full items-center justify-between text-left outline-none disabled:cursor-not-allowed", className)}
{...props}
>
<span data-slot="combobox-value">{selected ? selected.label : placeholder}</span>
<ChevronDownIcon data-slot="combobox-icon" aria-hidden="true" />
</button>
</PopoverTrigger>
<PopoverContent
ref={popupRef}
data-combobox-popup=""
data-size={size}
align="start"
sideOffset={-1}
style={
{
"--hm-popover-padding": "0px",
"--hm-popover-width": "var(--radix-popover-trigger-width)",
} as React.CSSProperties
}
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>{emptyText}</CommandEmpty>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
keywords={[option.label]}
disabled={option.disabled}
data-checked={option.value === value ? "" : undefined}
onSelect={() => select(option.value)}
>
<span data-slot="combobox-item-label">{option.label}</span>
<CheckIcon
data-slot="combobox-item-check"
aria-hidden="true"
className={option.value === value ? "opacity-100" : "opacity-0"}
/>
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
export { Combobox }
export type { ComboboxOption, ComboboxSize }