import { ReactNode, Ref } from 'react'; import { Node, Key, Collection } from 'react-stately'; import { ComboBoxRenderProps, InputProps as RaInputProps, ListBoxProps as RaListBoxProps, ListBoxItemProps as RaListBoxItemProps, ListBoxSectionProps as RaListBoxSectionProps } from 'react-aria-components'; import { HTMLChakraProps, SlotRecipeProps, UnstyledProp } from '@chakra-ui/react/styled-system'; import { TagGroupProps as NimbusTagGroupProps } from '../tag-group/tag-group.types'; import { PopoverProps as NimbusPopoverProps } from '../popover/popover.types'; import { OmitInternalProps } from '../../type-utils/omit-props'; type ComboBoxRecipeProps = { /** * Size variant of combobox * @default "md" */ size?: SlotRecipeProps<"nimbusCombobox">["size"]; /** * Variant of combobox * @default "solid" */ variant?: SlotRecipeProps<"nimbusCombobox">["variant"]; } & UnstyledProp; export type ComboBoxRootSlotProps = HTMLChakraProps<"div", ComboBoxRecipeProps>; export type ComboBoxTriggerSlotProps = HTMLChakraProps<"div">; export type ComboBoxLeadingElementSlotProps = HTMLChakraProps<"div">; export type ComboBoxContentSlotProps = HTMLChakraProps<"div">; export type ComboBoxTagGroupSlotProps = HTMLChakraProps<"div">; export type ComboBoxInputSlotProps = HTMLChakraProps<"div">; export type ComboBoxPopoverSlotProps = HTMLChakraProps<"div">; export type ComboBoxListBoxSlotProps = HTMLChakraProps<"div">; export type ComboBoxOptionSlotProps = HTMLChakraProps<"div">; export type ComboBoxOptionIndicatorSlotProps = HTMLChakraProps<"div">; export type ComboBoxOptionContentSlotProps = HTMLChakraProps<"div">; export type ComboBoxSectionSlotProps = HTMLChakraProps<"div">; /** * Filter function for collection items * * @param nodes - Collection nodes to filter (includes sections and items) * @param inputValue - Current filter text from input * @returns Filtered collection nodes */ export type ComboBoxFilter = (nodes: Iterable>, inputValue: string) => Iterable>; /** * Async configuration for ComboBox with built-in useAsyncList integration * * @template T - Type for item data returned by the load function */ export type ComboBoxAsyncConfig = { /** * Async function to load items based on filter text. * Automatically receives an AbortSignal for request cancellation. * * @param filterText - Current input value to filter by * @param signal - AbortSignal for cancelling the request * @returns Promise resolving to array of items * * @example * ```tsx * async: {{ * load: async (filterText, signal) => { * const response = await fetch( * `/api/search?q=${encodeURIComponent(filterText)}`, * { signal } * ); * const data = await response.json(); * return data.results; * } * }} * ``` */ load: (filterText: string, signal: AbortSignal) => Promise; /** * Minimum number of characters required before triggering a load. * Prevents unnecessary API calls for very short queries. * * @default 0 * * @example * ```tsx * async: {{ load, minSearchLength: 2 }} // Only search after typing 2+ chars * ``` */ minSearchLength?: number; /** * Debounce delay in milliseconds before triggering load. * Prevents excessive API calls while user is typing. * * @default 300 * * @example * ```tsx * async: {{ load, debounce: 500 }} // Wait 500ms after typing stops * ``` */ debounce?: number; /** * Callback when an error occurs during loading. * Receives the error object for custom error handling. * * @param error - The error that occurred * * @example * ```tsx * async: {{ * load, * onError: (error) => { * console.error('Failed to load:', error); * toast.error('Search failed. Please try again.'); * } * }} * ``` */ onError?: (error: Error) => void; }; export type ComboBoxRootContextValue = { /** Selection mode determines single vs multi-select behavior */ selectionMode: "single" | "multiple"; /** variant size */ size?: SlotRecipeProps<"nimbusCombobox">["size"]; /** Extract key from item for TagGroup */ getKey: (item: T) => Key; /** Extract text value from item for TagGroup */ getTextValue: (item: T) => string; /** Leading visual element (e.g., search icon) rendered before the input */ leadingElement?: ReactNode; /** Ref to trigger element (for popover positioning) */ triggerRef: React.RefObject; /** Ref to input element (for programmatic focus) */ inputRef: React.RefObject; /** Whether component is disabled */ isDisabled: boolean; /** Whether component is required */ isRequired: boolean; /** Whether component is invalid */ isInvalid: boolean; /** Whether component is read-only */ isReadOnly: boolean; /** Loading state */ isLoading?: boolean; }; /** * Root component props for unified ComboBox * * @template T - Type for item data displayed in the combobox menu. Items array type is `T[]`. */ export type ComboBoxRootProps = Omit & { /** * Render function for each item, or static JSX children. * Must be React Aria collection elements (Section/Item components). */ children?: ReactNode | ((values: T & { defaultChildren: ReactNode | undefined; } & ComboBoxRenderProps) => ReactNode); /** * Selection mode - determines single vs multi-select behavior * @default "single" */ selectionMode?: "single" | "multiple"; /** * Collection items to display. * If `children` is not provided, each item will be rendered using `getTextValue`. * * When `allowsCustomOptions` is enabled: * - New items created via Enter key are automatically added to the collection * - Both the items array and selection state are managed internally * - Use `onCreateOption` callback for side effects (API calls, notifications, etc.) */ items?: Iterable; /** * Function to extract unique key from each item when items do not have a `key` or `id` field. * Defaults to using `item.key` or `item.id`. * * **Important:** Wrap this function in `useCallback` to prevent unnecessary * re-synchronization of the list state. * * @param item - The item to extract the key from * @returns The unique key for the item * @default useCallback((item) => (item.key ?? item.id), []) * @example * ```tsx * // ✅ Good: Memoized with useCallback * const getKey = useCallback((item) => item.productId, []); * * * // ❌ Bad: Inline function creates new identity on every render * item.productId} /> * ``` */ getKey?: (item: T) => Key; /** * Function to extract display text from each item when `children` is not provided. * Used for both rendering and filtering. * Defaults to `item.label ?? item.name ?? String(item)`. * * **Important:** Wrap this function in `useCallback` for optimal performance. * * @param item - The item to extract text from * @returns The display text for the item * @default useCallback((item) => item.label ?? item.name ?? String(item), []) * @example * ```tsx * // ✅ Good: Memoized with useCallback * const getTextValue = useCallback((item) => item.displayName, []); * * * // ❌ Bad: Inline function creates new identity on every render * item.displayName} /> * ``` */ getTextValue?: (item: T) => string; /** * Controlled selected keys (unified for single/multi) * Single-select: pass a Key * Multi-select: pass a Key[] * * Internally normalized to Set for React Stately */ selectedKeys?: Key[]; /** * Callback when selection changes * Always receives an array of keys regardless of selection mode: * - Single-select: `[selectedKey]` or `[]` if nothing selected * - Multi-select: `[key1, key2, ...]` or `[]` if nothing selected */ onSelectionChange?: (keys: Key[]) => void; /** * Keys of items that should be disabled and not selectable. * Disabled items are still visible but cannot be selected or focused. * * @example * ```tsx * * ``` */ disabledKeys?: Iterable; /** * Input value for controlled mode * * **Controlled Mode:** When `onInputChange` is provided, you manage the input value. * You **MUST** update this prop in response to `onInputChange` for changes to be visible: * - User typing in the input * - Automatic sync on selection changes (single-select mode) * - Input clearing after selection (multi-select mode) * * **Uncontrolled Mode:** When `onInputChange` is NOT provided, the component manages * the input value internally. You can still pass `inputValue` to set the initial value. * * @example * ```tsx * // ✅ Controlled: Update inputValue in response to onInputChange * const [inputValue, setInputValue] = useState(""); * * * // ✅ Uncontrolled: Component manages input internally * * * // ❌ Wrong: Controlled prop but never updated * console.log(v)} // Input will be frozen * /> * ``` */ inputValue?: string; /** * Callback when input value changes * * **When provided:** Enables controlled input mode. You must update `inputValue` prop * in this callback for the input to update. * * **When NOT provided:** Component uses uncontrolled input mode and manages value internally. * * Called when: * - User types in the input * - Selection changes in single-select mode (input syncs to selected item's text) * - Option selected in multi-select mode (input clears) * * @param value - The new input value */ onInputChange?: (value: string) => void; /** * Custom filter function for collection. * If not provided, uses default text-based filter. * * **Important:** Wrap this function in `useCallback` to prevent * unnecessary re-filtering of the collection on every render. * * @param nodes - Collection nodes to filter (includes sections and items) * @param inputValue - Current filter text from input * @returns Filtered collection nodes * * @example * ```tsx * // ✅ Good: Memoized with useCallback * const customFilter = useCallback((nodes, inputValue) => { * if (!inputValue) return nodes; * return Array.from(nodes).filter(node => * node.textValue?.toLowerCase().includes(inputValue.toLowerCase()) * ); * }, []); * * * * // ❌ Bad: Inline function creates new identity on every render * ...} // Will cause re-filtering on every render * /> * ``` * * **Built-in Utilities:** For common patterns, use the exported filter utilities: * - `filterByTextWithSections` - Section-aware filtering * - `createSectionAwareFilter` - Factory for custom section-aware filters * - `createMultiPropertyFilter` - Multi-property search filtering */ filter?: ComboBoxFilter; /** * Placeholder text for input */ placeholder?: string; /** * Controls when the menu opens. * - "focus": Opens when input receives focus * - "input": Opens when user types (input value changes) * - "manual": Only opens via button click or arrow down key * @default "input" */ menuTrigger?: "focus" | "input" | "manual"; /** * Whether the menu should close when the combobox loses focus. * Set to false to keep menu open when clicking outside. * @default true */ shouldCloseOnBlur?: boolean; /** * Whether to close the menu after an item is selected (single-select only). * Multi-select always keeps menu open after selection. * @default true */ shouldCloseOnSelect?: boolean; /** * Controlled open state of the menu */ isOpen?: boolean; /** * Default open state for uncontrolled mode * @default false */ defaultOpen?: boolean; /** * Callback when menu open state changes */ onOpenChange?: (isOpen: boolean) => void; /** * Whether to keep the menu open when the filtered collection is empty. * When false (default), the menu automatically closes when no items match the filter. * When true, the menu stays open and can show empty state content. * @default false */ allowsEmptyMenu?: boolean; /** * Custom render function for empty state when no items match the filter. * Only shown when `allowsEmptyMenu` is true and collection is empty. * Passed through to React Aria's ListBox renderEmptyState prop. * @example * ```tsx * ( * * No results found * * )} * /> * ``` */ renderEmptyState?: () => ReactNode; /** * External loading state for displaying loading indicators. * Useful when managing async data loading externally with React Stately's useAsyncList. * * **Note:** When using the built-in `async` prop, loading state is managed automatically. * This prop is only needed for external async management. * * @example * ```tsx * // External async control with useAsyncList * const asyncList = useAsyncList({ * async load({ filterText, signal }) { * const res = await fetch(`/api/search?q=${filterText}`, { signal }); * return { items: await res.json() }; * } * }); * * asyncList.setFilterText(value)} * > * {(item) => {item.name}} * * ``` */ isLoading?: boolean; /** * Built-in async loading configuration with automatic state management. * * When provided, ComboBox automatically handles: * - Loading state management * - Request debouncing (300ms default) * - Request cancellation on input changes * - Automatic filter bypass (since API handles filtering) * - Minimum search length validation * - Error handling with optional callback * * **Important:** When using `async`, do NOT provide `items`, `isLoading`, `inputValue`, or `onInputChange`. * These are managed automatically by the internal useAsyncList integration. * * @example * ```tsx * // Simple async search * { * const response = await fetch( * `/api/search?q=${encodeURIComponent(filterText)}`, * { signal } * ); * return response.json(); * } * }} * > * {(item: SearchResult) => ( * {item.name} * )} * * * // With configuration and error handling * { * const response = await fetch(`/api/search?q=${filterText}`, { signal }); * const data = await response.json(); * return data.results; * }, * minSearchLength: 2, * debounce: 500, * onError: (error) => { * console.error('Search failed:', error); * toast.error('Failed to load results'); * } * }} * /> * ``` */ async?: ComboBoxAsyncConfig; /** * Whether to allow creating custom options that don't exist in the items collection. * When enabled and input doesn't match any existing option, pressing Enter will create * a new option using `getNewOptionData`. * * Requires providing `getNewOptionData` to transform input value into item object. * @default false * @example * ```tsx * ({ * id: `tag-${Date.now()}`, * label: inputValue, * })} * onCreateOption={(inputValue) => { * console.log('Created new tag:', inputValue); * }} * /> * ``` */ allowsCustomOptions?: boolean; /** * Function to validate whether the current input value is valid for creating a new option. * If not provided, any non-empty input that doesn't match an existing option is valid. * * Based on react-select's isValidNewOption API. * @param inputValue - The current input value * @returns True if the input is valid for creating a new option * @example * ```tsx * // Only allow creating tags that start with '#' * isValidNewOption={(inputValue) => { * return inputValue.startsWith('#') && inputValue.length > 1; * }} * * // Only allow email addresses * isValidNewOption={(inputValue) => { * return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inputValue); * }} * ``` */ isValidNewOption?: (inputValue: string) => boolean; /** * Function to transform the input value into a new item object when creating a custom option. * Required when `allowsCustomValue` is true. * * The returned item must be compatible with your items type and have properties * that `getKey` and `getTextValue` can extract. * * Based on react-select's getNewOptionData API. * @param inputValue - The input value to transform * @returns A new item object to add to the collection * @example * ```tsx * // Simple tag creation * getNewOptionData={(inputValue) => ({ * id: `tag-${Date.now()}`, * label: inputValue, * })} * * // Product creation with additional fields * getNewOptionData={(inputValue) => ({ * id: `product-${Date.now()}`, * name: inputValue, * category: 'uncategorized', * createdAt: new Date(), * })} * ``` */ getNewOptionData?: (inputValue: string) => T; /** * Callback when a new option is created via Enter key on valid input. * Called after the item is added to the collection and selected. * * Use this for side effects like API calls, analytics, or notifications. * * Based on react-select's onCreateOption API. * Note: Unlike react-select, selection changes are still handled by `onSelectionChange`. * @param newOption - The option that was created * @example * ```tsx * onCreateOption={async (newOption) => { * // Make API call to persist * await createTagAPI(newOption); * * // Show notification * toast.success(`Created tag: ${newOption.value}`); * }} * ``` */ onCreateOption?: (newOption: T) => void; /** * Accessible label for the combobox (when no visible label exists) * @example "Select a product" */ "aria-label"?: string; /** * ID of external label element (typically from FormField) * Use this when the combobox is wrapped in a FormField component * @example "product-combobox-label" */ "aria-labelledby"?: string; /** * Leading visual element rendered before the input * Common use cases: search icon, category indicator * * **Accessibility**: Ensure decorative elements have aria-hidden="true". * If the element is functional (clickable), it needs its own aria-label. * * @example * ```tsx * // Decorative icon (recommended) *