'use client'; import { cva } from 'class-variance-authority'; import equal from 'fast-deep-equal'; import * as React from 'react'; import { BasicField, BasicFieldOptions, extractBasicFieldProps, } from '../basic-field'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandProps, } from '../command'; import { cn, ComponentAnatomy, defineStyleAnatomy } from '../core/styling'; import { mergeRefs } from '../core/utils'; import { extractInputPartProps, hiddenInputStyles, InputAddon, InputAnatomy, InputContainer, InputIcon, InputStyling, } from '../input'; import { Popover } from '../popover'; /* ------------------------------------------------------------------------------------------------- * Anatomy * -----------------------------------------------------------------------------------------------*/ export const ComboboxAnatomy = defineStyleAnatomy({ root: cva(['UI-Combobox__root', 'justify-between h-auto'], { variants: { size: { sm: 'min-h-8 px-2 py-1 text-sm', md: 'min-h-10 px-3 py-2 ', lg: 'min-h-12 px-4 py-3 text-md', }, }, defaultVariants: { size: 'md', }, }), popover: cva([ 'UI-Combobox__popover', 'w-[--radix-popover-trigger-width] p-0', ]), checkIcon: cva([ 'UI-Combobox__checkIcon', 'h-4 w-4', 'data-[selected=true]:opacity-100 data-[selected=false]:opacity-0', ]), item: cva([ 'UI-Combobox__item', 'flex gap-1 items-center flex-none truncate bg-gray-100 dark:bg-gray-800 px-2 pr-1 rounded-[--radius] max-w-96', ]), placeholder: cva(['UI-Combobox__placeholder', 'text-[--muted] truncate']), inputValuesContainer: cva([ 'UI-Combobox__inputValuesContainer', 'grow flex overflow-hidden gap-2 flex-wrap', ]), chevronIcon: cva([ 'UI-Combobox__chevronIcon', 'ml-2 h-4 w-4 shrink-0 opacity-50', ]), removeItemButton: cva([ 'UI-Badge__removeItemButton', 'text-lg cursor-pointer transition ease-in hover:opacity-60', ]), }); /* ------------------------------------------------------------------------------------------------- * Combobox * -----------------------------------------------------------------------------------------------*/ export type ComboboxOption = { value: string; textValue?: string; label: React.ReactNode; }; export type ComboboxProps = Omit< React.ComponentPropsWithRef<'button'>, 'size' | 'value' > & BasicFieldOptions & InputStyling & ComponentAnatomy & { /** * The selected values */ value?: string[]; /** * Callback fired when the selected values change */ onValueChange?: (value: string[]) => void; /** * Callback fired when the search input changes */ onTextChange?: (value: string) => void; /** * Additional props for the command component */ commandProps?: CommandProps; /** * The options to display in the dropdown */ options: ComboboxOption[]; /** * The message to display when there are no options */ emptyMessage: React.ReactNode; /** * The placeholder text */ placeholder?: string; /** * Allow multiple values to be selected */ multiple?: boolean; /** * Default value when uncontrolled */ defaultValue?: string[]; /** * Ref to the input element */ inputRef?: React.Ref; /** * Close the popover when an item is selected */ keepOpenOnSelect?: boolean; }; export const Combobox = React.forwardRef( (props, ref) => { const [props1, basicFieldProps] = extractBasicFieldProps( props, React.useId() ); const [ { size, intent, leftAddon, leftIcon, rightAddon, rightIcon, className, popoverClass, checkIconClass, itemClass, placeholderClass, inputValuesContainerClass, chevronIconClass, removeItemButtonClass, /**/ commandProps, options, emptyMessage, placeholder, value: controlledValue, onValueChange, onTextChange, multiple = false, defaultValue, inputRef, keepOpenOnSelect = true, ...rest }, { inputContainerProps, leftAddonProps, leftIconProps, rightAddonProps, rightIconProps, }, ] = extractInputPartProps({ ...props1, size: props1.size ?? 'md', intent: props1.intent ?? 'basic', leftAddon: props1.leftAddon, leftIcon: props1.leftIcon, rightAddon: props1.rightAddon, rightIcon: props1.rightIcon, }); const buttonRef = React.useRef(null); const valueRef = React.useRef( controlledValue || defaultValue || [] ); const [value, setValue] = React.useState( controlledValue || defaultValue || [] ); const [open, setOpen] = React.useState(false); const handleUpdateValue = React.useCallback((value: string[]) => { setValue(value); valueRef.current = value; }, []); React.useEffect(() => { if ( controlledValue !== undefined && !equal(controlledValue, valueRef.current) ) { handleUpdateValue(controlledValue); } }, [controlledValue]); React.useEffect(() => { onValueChange?.(value); }, [value]); const selectedOptions = options.filter((option) => value.includes(option.value) ); const selectedValues = !!value.length && !!selectedOptions.length ? ( multiple ? ( selectedOptions.map((option) => (
{option.textValue || option.value} { e.preventDefault(); handleUpdateValue(value.filter((v) => v !== option.value)); setOpen(false); }} >
)) ) : ( {selectedOptions[0].label} ) ) : ( {placeholder} ); return (
{selectedValues}
{!!value.length && !!selectedOptions.length && !multiple && ( { e.preventDefault(); handleUpdateValue([]); setOpen(keepOpenOnSelect); }} > )}
} > {emptyMessage} {options.map((option) => ( { const _option = options.find( (n) => (n.textValue || n.value).toLowerCase() === currentValue.toLowerCase() ); if (_option) { if (!multiple) { handleUpdateValue( value.includes(_option.value) ? [] : [_option.value] ); } else { handleUpdateValue( !value.includes(_option.value) ? [...value, _option.value] : value.filter((v) => v !== _option.value) ); } } setOpen(keepOpenOnSelect); }} leftIcon={ } > {option.label} ))}
{}} onFocusCapture={() => buttonRef.current?.focus()} />
); } ); Combobox.displayName = 'Combobox';