'use client'; import * as React from 'react'; import { useIsoLayoutEffect } from '../../hooks/useIsoLayoutEffect'; import { useRefWithInit } from '../../hooks/useRefWithInit'; import { useTransitionStatus } from '../../internals/useTransitionStatus'; import { usePopupRootHandle } from '../../utils/popups/usePopupRootHandle'; import { useFilter } from '../../filter/useFilter'; import { useStableCallback } from '../../hooks/useStableCallback'; import { useFieldControlRegistration } from '../../internals/field/useFieldControlRegistration'; import { compareItemEquality, defaultItemEquality, type ItemEqualityComparer, } from '../../internals/itemEquality'; import type { ComboboxHandle } from '../store/ComboboxHandle'; import { ComboboxStore, flattenComboboxEntries, isComboboxGroup, normalizeComboboxItems, resolveComboboxSelection, type ComboboxEntry, type ComboboxItem, type ComboboxItems, } from '../store/ComboboxStore'; import type { ComboboxRoot } from './ComboboxRoot'; import { ComboboxItemsContext } from './ComboboxItemsContext'; import { ComboboxSelectionContext } from './ComboboxSelectionContext'; import { ComboboxRootContext } from './ComboboxRootContext'; import { ComboboxTransitionContext } from './ComboboxTransitionContext'; import { useContextCallback, useControlledProp, useStoreState, useSyncedValues } from '../../store/ReactStore'; export type ComboboxMode = 'combobox' | 'autocomplete'; /** * The props both `Combobox.Root` and `Autocomplete.Root` accept. An * autocomplete's value *is* its input text, so it omits the selection props. */ export interface UseRenderComboboxRootProps { items?: ComboboxItems | undefined; value?: unknown; defaultValue?: unknown; onValueChange?: ((value: any, eventDetails: ComboboxRoot.ChangeEventDetails) => void) | undefined; multiple?: boolean | undefined; isItemEqualToValue?: ItemEqualityComparer | undefined; inputValue?: string | undefined; defaultInputValue?: string | undefined; onInputValueChange?: | ((value: string, eventDetails: ComboboxRoot.ChangeEventDetails) => void) | undefined; open?: boolean | undefined; defaultOpen?: boolean | undefined; onOpenChange?: ((open: boolean, eventDetails: ComboboxRoot.ChangeEventDetails) => void) | undefined; onOpenChangeComplete?: | ((open: boolean, eventDetails: ComboboxRoot.ChangeEventDetails) => void) | undefined; openOnFocus?: boolean | undefined; filter?: ((item: ComboboxItem, query: string) => boolean) | undefined; disabled?: boolean | undefined; disablePointerDismissal?: boolean | undefined; actionsRef?: React.RefObject | undefined; handle?: ComboboxHandle | undefined; triggerId?: string | null | undefined; defaultTriggerId?: string | null | undefined; children?: React.ReactNode | ((payload: Payload) => React.ReactNode); } /** * The shared Root implementation behind `Combobox.Root` and `Autocomplete.Root`. * * The two differ only in whether choosing an item records a separate value: * a combobox selects `item.value` and shows its label, an autocomplete puts the * typed text itself forward and treats the list as suggestions. */ export function useRenderComboboxRoot( props: UseRenderComboboxRootProps, mode: ComboboxMode, ) { const { actionsRef, children, defaultInputValue, defaultOpen = false, defaultTriggerId = null, defaultValue, disablePointerDismissal = false, disabled: disabledProp = false, filter, handle, inputValue, isItemEqualToValue = defaultItemEquality, items, multiple = false, onInputValueChange, onOpenChange, onOpenChangeComplete, onValueChange, open, openOnFocus = true, triggerId, value, } = props; // `entries` keeps the grouping the consumer wrote; `normalizedItems` is the flat // list of selectable items, which is what every selection lookup runs against. const entries = React.useMemo(() => normalizeComboboxItems(items), [items]); const normalizedItems = React.useMemo(() => flattenComboboxEntries(entries), [entries]); // A combobox reports itself to a surrounding `Field.Root` exactly as `Select` // does. An autocomplete has no separate selection, so the text it holds *is* // its value as far as the field is concerned. const ownsText = mode === 'autocomplete'; // The field needs a way to focus the input when a form stops on this control, // but the input's ref lives in the store, which is created below. A stable // shim reads it through a ref the effect fills in. const storeRef = React.useRef(null); const fieldFocusRef = useRefWithInit(() => ({ focus() { storeRef.current?.select('inputRef')?.current?.focus(); }, })); const { fieldDisabled, markChanged, markTouched } = useFieldControlRegistration({ initialValue: ownsText ? (defaultInputValue ?? inputValue ?? '') : (defaultValue ?? value ?? null), ownsValue: true, focusRef: fieldFocusRef, }); const disabled = disabledProp || fieldDisabled; const { contains } = useFilter(); const filterItem = React.useMemo( () => filter ?? ((item: ComboboxItem, query: string) => contains(item.label, query)), [filter, contains], ); const store = useRefWithInit( () => new ComboboxStore({ open: defaultOpen, openProp: open, // A multiple combobox's selection is a list, so an omitted default is an // empty one rather than "nothing selected". value: defaultValue ?? (multiple ? [] : null), valueProp: value, // The initial input text is only ever read here, and this closure only runs on the // first render — so deriving it inline keeps it stable. Recomputing it later would // make a selection that changes the controlled `value` (and with it the derived // label) look like a controlled/uncontrolled switch. A multiple combobox never // shows its selection in the input, so it starts blank. inputValue: defaultInputValue ?? (mode === 'combobox' && !multiple ? (normalizedItems.find((item) => compareItemEquality(item.value, value ?? defaultValue, isItemEqualToValue), )?.label ?? '') : ''), inputValueProp: inputValue, items: normalizedItems, mode, multiple, isItemEqualToValue, disabled, disablePointerDismissal, openOnFocus, triggerId: defaultTriggerId, triggerIdProp: triggerId, }), ).current; // Wrapping the consumer's callbacks is what lets a combobox report itself to a // surrounding `Field.Root`: choosing a value makes the field dirty, and // closing the list is the combobox's equivalent of a blur. const handleValueChange = useStableCallback( (nextValue: unknown, eventDetails: ComboboxRoot.ChangeEventDetails) => { onValueChange?.(nextValue, eventDetails); if (eventDetails.isCanceled || ownsText) { return; } markChanged(nextValue); }, ); const handleInputValueChange = useStableCallback( (nextValue: string, eventDetails: ComboboxRoot.ChangeEventDetails) => { onInputValueChange?.(nextValue, eventDetails); if (eventDetails.isCanceled || !ownsText) { return; } markChanged(nextValue); }, ); const handleOpenChange = useStableCallback( (nextOpen: boolean, eventDetails: ComboboxRoot.ChangeEventDetails) => { onOpenChange?.(nextOpen, eventDetails); if (eventDetails.isCanceled || nextOpen) { return; } markTouched(ownsText ? store.select('inputValue') : store.select('value')); }, ); useIsoLayoutEffect(() => { storeRef.current = store; }, [store]); useControlledProp(store, 'openProp', open); useControlledProp(store, 'valueProp', value); useControlledProp(store, 'inputValueProp', inputValue); useControlledProp(store, 'triggerIdProp', triggerId); useContextCallback(store, 'onOpenChange', handleOpenChange); useContextCallback(store, 'onOpenChangeComplete', onOpenChangeComplete); useContextCallback(store, 'onValueChange', handleValueChange); useContextCallback(store, 'onInputValueChange', handleInputValueChange); useSyncedValues(store, { mode, multiple, isItemEqualToValue, disabled, disablePointerDismissal, openOnFocus, items: normalizedItems, }); usePopupRootHandle({ store, handle, actionsRef }); const resolvedOpen = useStoreState(store, 'open'); const selectedValue = useStoreState(store, 'value'); const currentInputValue = useStoreState(store, 'inputValue'); // The label of the currently selected value. Only single selection has one: // a multiple combobox keeps its input free for the query, and an autocomplete // has no selection at all. const singleSelection = mode === 'combobox' && !multiple; const selectedLabel = singleSelection ? (normalizedItems.find((item) => compareItemEquality(item.value, selectedValue, isItemEqualToValue), )?.label ?? '') : ''; // Reflect a controlled `value` that changed from outside into the input text. // `selectItem` already sets both, so the ref guard makes that a no-op and stops // this from fighting the user's typing (typing never changes the selection). const lastReflectedValueRef = React.useRef(selectedValue); useIsoLayoutEffect(() => { if (!singleSelection || selectedValue === lastReflectedValueRef.current) { return; } lastReflectedValueRef.current = selectedValue; store.reflectInputValue(selectedLabel); }, [singleSelection, selectedValue, selectedLabel, store]); const filteredItems = React.useMemo(() => { // When the input still shows the current selection, the user has not started // filtering — show every item so a fresh focus reveals the whole list rather // than the single selected row. Only single selection can be in that state; // everywhere else the input holds nothing but the query. const query = singleSelection && currentInputValue === selectedLabel ? '' : currentInputValue.trim(); if (query.length === 0) { return entries; } // Filtering runs inside a group and the group survives only if something in // it did. A group is never matched on its own label: a query that happens to // spell a group name should not resurrect every item under it. const matches: ComboboxEntry[] = []; for (const entry of entries) { if (!isComboboxGroup(entry)) { if (filterItem(entry, query)) { matches.push(entry); } continue; } const items = entry.items.filter((item) => filterItem(item, query)); if (items.length > 0) { matches.push({ ...entry, items }); } } return matches; }, [entries, currentInputValue, selectedLabel, singleSelection, filterItem]); // Groups are structure, not content — the list is empty when no selectable // item survived, whatever wrappers are left standing. const filteredItemCount = React.useMemo( () => flattenComboboxEntries(filteredItems).length, [filteredItems], ); const selectedItems = React.useMemo( () => resolveComboboxSelection(normalizedItems, selectedValue, multiple, isItemEqualToValue), [normalizedItems, selectedValue, multiple, isItemEqualToValue], ); const { transitionStatus } = useTransitionStatus(resolvedOpen, false, true); const itemsContextValue = React.useMemo( () => ({ filteredItems, filteredItemCount }), [filteredItems, filteredItemCount], ); // Kept apart from the filtered items: they change for different reasons, and // sharing one context would re-render the whole list on every selection. const selectionContextValue = React.useMemo(() => ({ selectedItems }), [selectedItems]); const transitionContextValue = React.useMemo(() => ({ transitionStatus }), [transitionStatus]); const payload = useStoreState(store, 'payload') as Payload; return ( {typeof children === 'function' ? children(payload) : children} ); }