'use client'; import { Combobox as ComboboxPrimitive } from '@base-ui/react'; import { useControlled } from '@base-ui/utils/useControlled'; import { createContext, RefObject, useCallback, useContext, useId, useMemo, useRef } from 'react'; import { useFieldContext } from '../field'; interface ComboboxContextValue { multiple: boolean; inputValue: string; hasItems: boolean; inputId: string; inputContainerRef: RefObject; value: Value | Value[] | null | undefined; getLabel: (value: Value) => string; } const ComboboxContext = createContext< ComboboxContextValue | undefined >(undefined); export const useComboboxContext = < Value = string >(): ComboboxContextValue => { const context = useContext(ComboboxContext); if (!context) { throw new Error( 'useComboboxContext must be used within a ComboboxProvider' ); } return context as ComboboxContextValue; }; export interface BaseComboboxRootProps extends Omit< ComboboxPrimitive.Root.Props, 'onValueChange' | 'onInputValueChange' | 'multiple' > { onInputValueChange?: (inputValue: string) => void; } export interface SingleComboboxProps extends BaseComboboxRootProps { multiple?: false; value?: Value | null; defaultValue?: Value | null; onValueChange?: (value: Value | null) => void; } export interface MultipleComboboxProps extends BaseComboboxRootProps { multiple: true; value?: readonly Value[]; defaultValue?: readonly Value[]; onValueChange?: (value: Value[]) => void; } export type ComboboxRootProps = | SingleComboboxProps | MultipleComboboxProps; export const ComboboxRoot = ({ multiple = false, children, onValueChange, onInputValueChange, inputValue: providedInputValue, defaultInputValue, value: providedValue, defaultValue, items, required, itemToStringLabel, id: idProp, ...props }: ComboboxRootProps) => { const generatedId = useId(); const id = idProp ?? generatedId; const fieldContext = useFieldContext(); const resolvedRequired = required ?? fieldContext?.required; const [inputValue, setInputValueUnwrapped] = useControlled({ controlled: providedInputValue === undefined ? undefined : String(providedInputValue), default: defaultInputValue === undefined ? '' : String(defaultInputValue), name: 'Combobox', state: 'inputValue' }); const [computedValue, setValueUnwrapped] = useControlled< Value | readonly Value[] | null >({ controlled: providedValue, default: defaultValue ?? null, name: 'Combobox', state: 'value' }); const inputContainerRef = useRef(null); const handleInputValueChange = useCallback( ( value: string, eventDetails: ComboboxPrimitive.Root.ChangeEventDetails ) => { setInputValueUnwrapped(value); onInputValueChange?.(value); }, [onInputValueChange, setInputValueUnwrapped] ); const handleValueChange = useCallback( ( value: Value | Value[] | null, eventDetails: ComboboxPrimitive.Root.ChangeEventDetails ) => { setValueUnwrapped(value); if (multiple) { (onValueChange as MultipleComboboxProps['onValueChange'])?.( value as Value[] ); } else { (onValueChange as SingleComboboxProps['onValueChange'])?.( value as Value | null ); } }, [onValueChange, multiple, setValueUnwrapped] ); // Same fallback order that Base UI uses for the input text. const getLabel = useCallback( (value: Value) => { if (itemToStringLabel) return itemToStringLabel(value); if (typeof value !== 'object' || value === null) return String(value ?? ''); if ('label' in value && value.label != null) return String(value.label); if ('value' in value) return String(value.value); return JSON.stringify(value); }, [itemToStringLabel] ); const contextValue = useMemo( () => ({ multiple, inputValue, hasItems: !!items, inputId: id, inputContainerRef, value: computedValue, getLabel }), [multiple, inputValue, items, id, computedValue, getLabel] ); return ( }> {children} ); }; ComboboxRoot.displayName = 'Combobox';