diff --git a/docs/src/app/(docs)/react/components/combobox/page.mdx b/docs/src/app/(docs)/react/components/combobox/page.mdx index 87a664f5681..6ba0fb144c8 100644 --- a/docs/src/app/(docs)/react/components/combobox/page.mdx +++ b/docs/src/app/(docs)/react/components/combobox/page.mdx @@ -74,7 +74,8 @@ import { Combobox } from '@base-ui/react/combobox'; ## TypeScript Combobox infers the item type from the `defaultValue` or `value` props passed to ``. -The type of items held in the `items` array must also match the `value` prop type passed to ``. +When the `items` array uses the full `{ value, label }` shape, `` can use either the whole item or its primitive `value` field. +For other item shapes, the `items` array type should match the `value` prop type passed to ``. ## Examples diff --git a/packages/react/src/combobox/chip/ComboboxChip.tsx b/packages/react/src/combobox/chip/ComboboxChip.tsx index 4937aff3bbb..85152897056 100644 --- a/packages/react/src/combobox/chip/ComboboxChip.tsx +++ b/packages/react/src/combobox/chip/ComboboxChip.tsx @@ -58,7 +58,7 @@ export const ComboboxChip = React.forwardRef(function ComboboxChip( stopEvent(event); - store.state.setIndices({ activeIndex: null, selectedIndex: null, type: 'keyboard' }); + store.state.setIndices({ activeIndex: null, type: 'keyboard' }); store.state.setSelectedValue( selectedValue.filter((_: any, i: number) => i !== index), createChangeEventDetails(REASONS.none, event.nativeEvent), diff --git a/packages/react/src/combobox/clear/ComboboxClear.tsx b/packages/react/src/combobox/clear/ComboboxClear.tsx index be56ea01a5e..20c582930d9 100644 --- a/packages/react/src/combobox/clear/ComboboxClear.tsx +++ b/packages/react/src/combobox/clear/ComboboxClear.tsx @@ -115,18 +115,13 @@ export const ComboboxClear = React.forwardRef(function ComboboxClear( Array.isArray(selectedValue) ? [] : null, createChangeEventDetails(REASONS.clearPress, event.nativeEvent), ); - store.state.setIndices({ - activeIndex: null, - selectedIndex: null, - type: keyboardActiveRef.current ? 'keyboard' : 'pointer', - }); - } else { - store.state.setIndices({ - activeIndex: null, - type: keyboardActiveRef.current ? 'keyboard' : 'pointer', - }); } + store.state.setIndices({ + activeIndex: null, + type: keyboardActiveRef.current ? 'keyboard' : 'pointer', + }); + store.state.inputRef.current?.focus(); }, }, diff --git a/packages/react/src/combobox/input/ComboboxInput.tsx b/packages/react/src/combobox/input/ComboboxInput.tsx index 55fa6c1992a..8ce8982e56c 100644 --- a/packages/react/src/combobox/input/ComboboxInput.tsx +++ b/packages/react/src/combobox/input/ComboboxInput.tsx @@ -165,7 +165,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( : highlightedChipIndex; // If the computed index is negative, treat it as no highlight. nextIndex = computedNextIndex >= 0 ? computedNextIndex : undefined; - store.state.setIndices({ activeIndex: null, selectedIndex: null, type: 'keyboard' }); + store.state.setIndices({ activeIndex: null, type: 'keyboard' }); } return nextIndex; } @@ -183,7 +183,7 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( event.currentTarget.value === '' && selectedValue.length > 0 ) { - store.state.setIndices({ activeIndex: null, selectedIndex: null, type: 'keyboard' }); + store.state.setIndices({ activeIndex: null, type: 'keyboard' }); event.preventDefault(); } @@ -296,7 +296,6 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( if (!autoHighlightEnabled) { store.state.setIndices({ activeIndex: null, - selectedIndex: null, type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer', }); } @@ -306,7 +305,6 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( if (open && store.state.activeIndex !== null && !shouldMaintainHighlight) { store.state.setIndices({ activeIndex: null, - selectedIndex: null, type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer', }); } @@ -343,7 +341,6 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( if (!autoHighlightEnabled) { store.state.setIndices({ activeIndex: null, - selectedIndex: null, type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer', }); } @@ -356,7 +353,6 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( if (open && store.state.activeIndex !== null && !autoHighlightEnabled) { store.state.setIndices({ activeIndex: null, - selectedIndex: null, type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer', }); } @@ -428,7 +424,6 @@ export const ComboboxInput = React.forwardRef(function ComboboxInput( // If the removed item was also the active (highlighted) item, clear highlight store.state.setIndices({ activeIndex: null, - selectedIndex: null, type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer', }); store.state.setSelectedValue( diff --git a/packages/react/src/combobox/item/ComboboxItem.tsx b/packages/react/src/combobox/item/ComboboxItem.tsx index 9ad1e25022c..4d0935762ee 100644 --- a/packages/react/src/combobox/item/ComboboxItem.tsx +++ b/packages/react/src/combobox/item/ComboboxItem.tsx @@ -14,10 +14,11 @@ import { import type { BaseUIComponentProps, HTMLProps, NonNativeButtonProps } from '../../internals/types'; import { useRenderElement } from '../../internals/useRenderElement'; import { ComboboxItemContext } from './ComboboxItemContext'; -import { selectors } from '../store'; +import { findMatchingItemIndex, selectors, type ComboboxStore, writeItemValues } from '../store'; import { useButton } from '../../internals/use-button'; import { useComboboxRowContext } from '../row/ComboboxRowContext'; -import { compareItemEquality, findItemIndex } from '../../internals/itemEquality'; +import { findItemIndex } from '../../internals/itemEquality'; +import { ComboboxPortalContext } from '../portal/ComboboxPortalContext'; /** * An individual item in the list. @@ -43,17 +44,19 @@ export const ComboboxItem = React.memo( const didPointerDownRef = React.useRef(false); const textRef = React.useRef(null); + const itemMetadata = React.useMemo(() => ({ value: itemValue }), [itemValue]); const listItem = useCompositeListItem({ index: indexProp, + metadata: itemMetadata, textRef, indexGuessBehavior: IndexGuessBehavior.GuessFromOrder, }); const store = useComboboxRootContext(); const isRow = useComboboxRowContext(); + const keepPortalMounted = React.useContext(ComboboxPortalContext); const { flatFilteredItems, hasItems } = useComboboxDerivedItemsContext(); - const open = useStore(store, selectors.open); const selectionMode = useStore(store, selectors.selectionMode); const readOnly = useStore(store, selectors.readOnly); const virtualized = useStore(store, selectors.virtualized); @@ -61,10 +64,9 @@ export const ComboboxItem = React.memo( const selectable = selectionMode !== 'none'; const index = - indexProp ?? - (virtualized - ? findItemIndex(flatFilteredItems, itemValue, isItemEqualToValue) - : listItem.index); + indexProp == null && virtualized + ? resolveVirtualIndex(hasItems, flatFilteredItems, itemValue, isItemEqualToValue) + : (indexProp ?? listItem.index); const hasRegistered = listItem.index !== -1; const rootId = useStore(store, selectors.id); @@ -73,63 +75,30 @@ export const ComboboxItem = React.memo( const itemProps = useStore(store, selectors.itemProps); const itemRef = React.useRef(null); - const id = rootId != null && hasRegistered ? `${rootId}-${index}` : undefined; const selected = matchesSelectedValue && selectable; + const manuallyIndexed = indexProp != null; + const shouldRegisterVirtualItem = hasRegistered && virtualized; + const shouldRegisterManuallyIndexedItem = hasRegistered && manuallyIndexed && !virtualized; useIsoLayoutEffect(() => { - const shouldRun = hasRegistered && (virtualized || indexProp != null); - if (!shouldRun) { + if (!shouldRegisterVirtualItem) { return undefined; } - const list = store.state.listRef.current; - list[index] = itemRef.current; - - return () => { - delete list[index]; - }; - }, [hasRegistered, virtualized, index, indexProp, store]); + return registerIndexedItem(store, hasItems, index, itemValue, itemRef, keepPortalMounted); + }, [hasItems, index, itemValue, keepPortalMounted, shouldRegisterVirtualItem, store]); - useIsoLayoutEffect(() => { - if (!hasRegistered || hasItems) { + // Manual indexes opt out of the CompositeList metadata map, but still render + // inside it. Register after its layout effects so its length sync cannot + // truncate these explicit slots. + React.useEffect(() => { + if (!shouldRegisterManuallyIndexedItem) { return undefined; } - const visibleMap = store.state.valuesRef.current; - visibleMap[index] = itemValue; - - // Stable registry that doesn't depend on filtering. Assume that no - // filtering had occurred at this point; otherwise, an `items` prop is - // required. - if (selectionMode !== 'none') { - store.state.allValuesRef.current.push(itemValue); - } - - return () => { - delete visibleMap[index]; - }; - }, [hasRegistered, hasItems, index, itemValue, store, selectionMode]); - - useIsoLayoutEffect(() => { - if (!open) { - didPointerDownRef.current = false; - return; - } - - if (!hasRegistered || hasItems) { - return; - } - - const selectedValue = store.state.selectedValue; - const lastSelectedValue = Array.isArray(selectedValue) - ? selectedValue[selectedValue.length - 1] - : selectedValue; - - if (compareItemEquality(itemValue, lastSelectedValue, isItemEqualToValue)) { - store.set('selectedIndex', index); - } - }, [hasRegistered, hasItems, open, store, index, itemValue, isItemEqualToValue]); + return registerIndexedItem(store, hasItems, index, itemValue, itemRef, keepPortalMounted); + }, [hasItems, index, itemValue, keepPortalMounted, shouldRegisterManuallyIndexedItem, store]); const { getButtonProps, buttonRef } = useButton({ disabled, @@ -213,6 +182,46 @@ export const ComboboxItem = React.memo( }), ); +function resolveVirtualIndex( + hasItems: boolean, + flatFilteredItems: readonly any[], + itemValue: any, + isItemEqualToValue: (itemValue: any, selectedValue: any) => boolean, +) { + return hasItems + ? findMatchingItemIndex(flatFilteredItems, itemValue, isItemEqualToValue) + : findItemIndex(flatFilteredItems, itemValue, isItemEqualToValue); +} + +function registerIndexedItem( + store: ComboboxStore, + hasItems: boolean, + index: number, + itemValue: any, + itemRef: React.RefObject, + keepMounted: boolean | undefined, +) { + const list = store.state.listRef.current; + const visibleMap = store.state.valuesRef.current; + + list[index] = itemRef.current; + visibleMap[index] = itemValue; + writeItemValues(store, hasItems, visibleMap.slice()); + + return () => { + delete list[index]; + const registryActive = + store.state.inline || store.state.open || keepMounted || store.state.forceMounted; + + if (!registryActive) { + return; + } + + delete visibleMap[index]; + writeItemValues(store, hasItems, visibleMap.slice()); + }; +} + export interface ComboboxItemState { /** * Whether the item should ignore user interaction. diff --git a/packages/react/src/combobox/list/ComboboxList.tsx b/packages/react/src/combobox/list/ComboboxList.tsx index f3c2edff2b2..73d6a75ecfa 100644 --- a/packages/react/src/combobox/list/ComboboxList.tsx +++ b/packages/react/src/combobox/list/ComboboxList.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; import { useStore } from '@base-ui/utils/store'; +import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect'; import { useStableCallback } from '@base-ui/utils/useStableCallback'; import type { BaseUIComponentProps } from '../../internals/types'; import { useRenderElement } from '../../internals/useRenderElement'; @@ -10,10 +11,15 @@ import { useComboboxRootContext, } from '../root/ComboboxRootContext'; import { useComboboxPositionerContext } from '../positioner/ComboboxPositionerContext'; -import { selectors } from '../store'; +import { selectors, writeItemValues } from '../store'; import { ComboboxCollection } from '../collection/ComboboxCollection'; import { CompositeList } from '../../internals/composite/list/CompositeList'; import { stopEvent } from '../../floating-ui-react/utils'; +import { ComboboxPortalContext } from '../portal/ComboboxPortalContext'; + +interface ComboboxListItemMetadata { + value: any; +} /** * A list container for the items. @@ -30,15 +36,21 @@ export const ComboboxList = React.forwardRef(function ComboboxList( const store = useComboboxRootContext(); const floatingRootContext = useComboboxFloatingContext(); const hasPositionerContext = Boolean(useComboboxPositionerContext(true)); + const keepPortalMounted = React.useContext(ComboboxPortalContext); const { filteredItems, hasItems } = useComboboxDerivedItemsContext(); const selectionMode = useStore(store, selectors.selectionMode); const grid = useStore(store, selectors.grid); const popupProps = useStore(store, selectors.popupProps); + const open = useStore(store, selectors.open); + const mounted = useStore(store, selectors.mounted); + const inline = useStore(store, selectors.inline); + const forceMounted = useStore(store, selectors.forceMounted); const virtualized = useStore(store, selectors.virtualized); const multiple = selectionMode === 'multiple'; const empty = filteredItems.length === 0; + const registryActive = inline || open || mounted || keepPortalMounted || forceMounted; const setPositionerElement = useStableCallback((element) => { store.set('positionerElement', element); @@ -48,6 +60,39 @@ export const ComboboxList = React.forwardRef(function ComboboxList( store.set('listElement', element); }); + const handleMapChange = useStableCallback( + (map: Map) => { + if (!registryActive) { + return; + } + + const itemValues: any[] = []; + map.forEach((metadata) => { + if (metadata) { + itemValues.push(metadata.value); + } + }); + + store.state.valuesRef.current = itemValues; + writeItemValues(store, hasItems, itemValues); + }, + ); + + useIsoLayoutEffect(() => { + if (registryActive) { + return; + } + + const itemValues = store.state.itemValues; + store.state.valuesRef.current = []; + if (itemValues.length) { + if (!hasItems) { + store.set('allItemValues', itemValues); + } + store.set('itemValues', []); + } + }, [hasItems, registryActive, store]); + // Support "closed template" API: if children is a function, implicitly wrap it // with a Combobox.Collection that reads items from context/root. // Ensures this component's `popupProps` subscription does not cause @@ -120,6 +165,7 @@ export const ComboboxList = React.forwardRef(function ComboboxList( {element} diff --git a/packages/react/src/combobox/root/AriaCombobox.tsx b/packages/react/src/combobox/root/AriaCombobox.tsx index ccc46e19a30..f514883ed02 100644 --- a/packages/react/src/combobox/root/AriaCombobox.tsx +++ b/packages/react/src/combobox/root/AriaCombobox.tsx @@ -31,13 +31,17 @@ import { ComboboxRootContext, ComboboxInputValueContext, } from './ComboboxRootContext'; -import { selectors, type State as StoreState } from '../store'; +import { findMatchingItemIndex, selectors, type State as StoreState } from '../store'; import { useOpenChangeComplete } from '../../internals/useOpenChangeComplete'; import { useFieldRootContext } from '../../internals/field-root-context/FieldRootContext'; import { useRegisterFieldControl } from '../../internals/field-register-control/useRegisterFieldControl'; import { useFormContext } from '../../internals/form-context/FormContext'; import { useLabelableId } from '../../internals/labelable-provider/useLabelableId'; -import { createCollatorItemFilter, createSingleSelectionCollatorFilter } from './utils'; +import { + createCollatorItemFilter, + createSingleSelectionCollatorFilter, + stringifyComboboxItemLabel, +} from './utils'; import { useCoreFilter } from './utils/useFilter'; import { useTransitionStatus } from '../../internals/useTransitionStatus'; import { useOpenInteractionType } from '../../utils/useOpenInteractionType'; @@ -47,6 +51,8 @@ import { NOOP } from '../../internals/noop'; import { FOCUSABLE_POPUP_PROPS } from '../../utils/popups'; import { mergeProps } from '../../merge-props'; import { + inferItemValue, + resolveSelectedLabel, stringifyAsLabel, stringifyAsValue, Group, @@ -55,7 +61,6 @@ import { import { compareItemEquality, defaultItemEquality, - findItemIndex, removeItem, selectedValueIncludes, } from '../../internals/itemEquality'; @@ -151,19 +156,12 @@ export function AriaCombobox( const clearRef = React.useRef(null); const selectionEventRef = React.useRef(null); const lastHighlightRef = React.useRef(INITIAL_LAST_HIGHLIGHT); - const pendingQueryHighlightRef = React.useRef(null); + const pendingQueryHighlightRef = React.useRef(null); /** * Contains the currently visible list of item values post-filtering. */ const valuesRef = React.useRef([]); - /** - * Contains all item values in a stable, unfiltered order. - * This is only used when `items` prop is not provided. - * It accumulates values on first mount and does not remove them on unmount due to - * filtering, providing a stable index for selected value tracking. - */ - const allValuesRef = React.useRef([]); const disabled = fieldDisabled || disabledProp; const name = fieldName ?? nameProp; @@ -187,6 +185,14 @@ export function AriaCombobox( state: 'selectedValue', }); + const selectedLabelString = React.useMemo(() => { + if (!single) { + return ''; + } + + return resolveLabelString(selectedValue, items, itemToStringLabel, isItemEqualToValue); + }, [single, selectedValue, items, itemToStringLabel, isItemEqualToValue]); + const filter = React.useMemo(() => { if (filterProp === null) { return () => true; @@ -195,10 +201,23 @@ export function AriaCombobox( return filterProp; } if (single && !queryChangedAfterOpen) { - return createSingleSelectionCollatorFilter(collatorFilter, itemToStringLabel, selectedValue); + return createSingleSelectionCollatorFilter( + collatorFilter, + itemToStringLabel, + selectedValue, + selectedLabelString, + ); } return createCollatorItemFilter(collatorFilter, itemToStringLabel); - }, [filterProp, single, selectedValue, queryChangedAfterOpen, collatorFilter, itemToStringLabel]); + }, [ + filterProp, + single, + selectedValue, + selectedLabelString, + queryChangedAfterOpen, + collatorFilter, + itemToStringLabel, + ]); // If neither inputValue nor defaultInputValue are provided, derive it from the // selected value for single mode so the input reflects the selection on mount. @@ -208,7 +227,7 @@ export function AriaCombobox( return defaultInputValueProp ?? ''; } if (single) { - return stringifyAsLabel(selectedValue, itemToStringLabel); + return selectedLabelString; } return ''; }, @@ -231,8 +250,6 @@ export function AriaCombobox( const isGrouped = isGroupedItems(items); const query = closeQuery ?? (inputValue === '' ? '' : String(inputValue).trim()); - const selectedLabelString = single ? stringifyAsLabel(selectedValue, itemToStringLabel) : ''; - const shouldBypassFiltering = single && !queryChangedAfterOpen && @@ -300,12 +317,9 @@ export function AriaCombobox( if (filterQuery === '') { return limit > -1 ? flatItems.slice(0, limit) - : // The cast here is done as `flatItems` is readonly. - // valuesRef.current, a mutable ref, can be set to `flatFilteredItems`, which may - // reference this exact readonly value, creating a mutation risk. - // However, can never mutate this value as the mutating effect - // bails early when `items` is provided, and this is only ever returned - // when `items` is provided due to the early return at the top of this hook. + : // This branch is only reached when the `items` prop is provided. + // `flatItemValues` is a mutable copy used by list navigation refs, while + // `flatItems` remains the source list used for filtering and rendering. (flatItems as Value[]); } @@ -340,6 +354,16 @@ export function AriaCombobox( return filteredItems as Value[]; }, [filteredItems, isGrouped]); + const flatItemValues = React.useMemo(() => flatItems.map(inferItemValue), [flatItems]); + + const flatFilteredItemValues = React.useMemo(() => { + if (flatFilteredItems === flatItems) { + return flatItemValues; + } + + return flatFilteredItems.map(inferItemValue); + }, [flatFilteredItems, flatItems, flatItemValues]); + const store = useRefWithInit( () => new Store({ @@ -362,7 +386,6 @@ export function AriaCombobox( chipsContainerRef, clearRef, valuesRef, - allValuesRef, selectionEventRef, name, form, @@ -384,7 +407,8 @@ export function AriaCombobox( transitionStatus: 'idle', inline: inlineProp, activeIndex: null, - selectedIndex: null, + itemValues: EMPTY_ARRAY, + allItemValues: EMPTY_ARRAY, popupProps: {}, inputProps: {}, triggerProps: {}, @@ -428,7 +452,6 @@ export function AriaCombobox( const onOpenChangeComplete = useStableCallback(onOpenChangeCompleteProp); const activeIndex = useStore(store, selectors.activeIndex); - const selectedIndex = useStore(store, selectors.selectedIndex); const positionerElement = useStore(store, selectors.positionerElement); const listElement = useStore(store, selectors.listElement); const triggerElement = useStore(store, selectors.triggerElement); @@ -437,8 +460,12 @@ export function AriaCombobox( const inline = useStore(store, selectors.inline); const inputInsidePopup = useStore(store, selectors.inputInsidePopup); const inputOwnsFormValue = useStore(store, selectors.inputOwnsFormValue); + const itemValues = useStore(store, selectors.itemValues); + const allItemValues = useStore(store, selectors.allItemValues); const triggerRef = useValueAsRef(triggerElement); + const [listNavigationSelectedValue, setListNavigationSelectedValue] = + React.useState(NO_ACTIVE_VALUE); const { mounted, setMounted, transitionStatus } = useTransitionStatus(open); const { openMethod, triggerProps } = useOpenInteractionType(open); @@ -456,25 +483,21 @@ export function AriaCombobox( if (items) { // Ensure typeahead works on a closed list. labelsRef.current = flatFilteredItems.map((item) => - stringifyAsLabel(item, itemToStringLabel), + stringifyComboboxItemLabel(item, itemToStringLabel), ); - } else { - store.set('forceMounted', true); + valuesRef.current = flatFilteredItemValues.slice(); + return; } - }); - const initialSelectedValueRef = React.useRef(selectedValue); - useIsoLayoutEffect(() => { - // Ensure the values and labels are registered for programmatic value changes. - if (selectedValue !== initialSelectedValueRef.current) { - forceMount(); - } - }, [forceMount, selectedValue]); + // Rendering is still needed when item metadata cannot be inferred from the + // `items` prop, for example object-valued rendered items used by closed + // trigger typeahead. + store.set('forceMounted', true); + }); const setIndices = useStableCallback( (options: { activeIndex?: number | null | undefined; - selectedIndex?: number | null | undefined; type?: 'none' | 'keyboard' | 'pointer' | undefined; }) => { store.update(options); @@ -528,7 +551,7 @@ export function AriaCombobox( } // Defer index updates until after the filtered items have been derived to ensure // `onItemHighlighted` receives the latest item. - pendingQueryHighlightRef.current = { hasQuery }; + pendingQueryHighlightRef.current = hasQuery; if (hasQuery && autoHighlightMode && store.state.activeIndex == null) { store.set('activeIndex', 0); } @@ -638,7 +661,7 @@ export function AriaCombobox( if (shouldFillInput) { setInputValue( - stringifyAsLabel(nextValue, itemToStringLabel), + resolveLabelString(nextValue, items, itemToStringLabel, isItemEqualToValue), createChangeEventDetails(eventDetails.reason, eventDetails.event), ); } @@ -722,15 +745,12 @@ export function AriaCombobox( const handleUnmount = useStableCallback(() => { setMounted(false); + store.set('forceMounted', false); onOpenChangeComplete?.(false); setQueryChangedAfterOpen(false); setCloseQuery(null); - if (selectionMode === 'none') { - setIndices({ activeIndex: null, selectedIndex: null }); - } else { - setIndices({ activeIndex: null }); - } + setIndices({ activeIndex: null }); // Multiple selection mode: // If the user typed a filter and didn't select in multiple mode, clear the input @@ -753,7 +773,7 @@ export function AriaCombobox( setInputValue('', createChangeEventDetails(REASONS.inputClear)); } } else { - const stringVal = stringifyAsLabel(selectedValue, itemToStringLabel); + const stringVal = selectedLabelString; if (inputRef.current && inputRef.current.value !== stringVal) { // If no selection was made, treat this as clearing the typed filter. const reason = stringVal === '' ? REASONS.inputClear : REASONS.none; @@ -787,47 +807,64 @@ export function AriaCombobox( React.useImperativeHandle(props.actionsRef, () => ({ unmount: handleUnmount }), [handleUnmount]); - useIsoLayoutEffect( - function syncSelectedIndex() { - if (open || selectionMode === 'none') { - return; - } + useIsoLayoutEffect(() => { + if (items) { + valuesRef.current = flatFilteredItemValues.slice(); + store.update({ + itemValues: EMPTY_ARRAY, + allItemValues: flatItems, + }); + } + }, [items, flatFilteredItemValues, flatItems, store]); - const registry = items ? flatItems : allValuesRef.current; + useIsoLayoutEffect(() => { + // Snapshot the selected value while the popup list is closed. Inline lists stay + // mounted, so their navigation state can read directly from the current store. + if (open) { + return; + } - if (multiple) { - const currentValue = Array.isArray(selectedValue) ? selectedValue : []; - const lastValue = currentValue[currentValue.length - 1]; - const lastIndex = findItemIndex(registry, lastValue, isItemEqualToValue); - setIndices({ selectedIndex: lastIndex === -1 ? null : lastIndex }); - } else { - const index = findItemIndex(registry, selectedValue, isItemEqualToValue); - setIndices({ selectedIndex: index === -1 ? null : index }); - } - }, - [ - open, - selectedValue, - items, - selectionMode, - flatItems, - multiple, - isItemEqualToValue, - setIndices, - ], - ); + let nextListNavigationSelectedValue = NO_ACTIVE_VALUE; + if (selectionMode !== 'none') { + nextListNavigationSelectedValue = + multiple && Array.isArray(selectedValue) + ? selectedValue[selectedValue.length - 1] + : selectedValue; + } + setListNavigationSelectedValue(nextListNavigationSelectedValue); + }, [multiple, open, selectedValue, selectionMode]); - useIsoLayoutEffect(() => { - if (items) { - valuesRef.current = flatFilteredItems; - listRef.current.length = flatFilteredItems.length; + const listNavigationRegistry = hasItems ? flatItems : allItemValues; + const listNavigationSelectedIndex = React.useMemo(() => { + if (queryChangedAfterOpen) { + return activeIndex; } - }, [items, flatFilteredItems]); + + if (selectionMode === 'none' || listNavigationSelectedValue === NO_ACTIVE_VALUE) { + return null; + } + + const index = findMatchingItemIndex( + listNavigationRegistry, + listNavigationSelectedValue, + isItemEqualToValue, + ); + return index === -1 ? null : index; + }, [ + activeIndex, + isItemEqualToValue, + listNavigationRegistry, + listNavigationSelectedValue, + queryChangedAfterOpen, + selectionMode, + ]); + const flatFilteredItemsLength = flatFilteredItems.length; useIsoLayoutEffect(() => { - const pendingHighlight = pendingQueryHighlightRef.current; - if (pendingHighlight) { - if (pendingHighlight.hasQuery) { + const pendingQueryHighlight = pendingQueryHighlightRef.current; + + if (pendingQueryHighlight !== null) { + if (pendingQueryHighlight) { if (autoHighlightMode) { store.set('activeIndex', 0); } @@ -841,8 +878,7 @@ export function AriaCombobox( return; } - const shouldUseFlatFilteredItems = hasItems || hasFilteredItemsProp; - const candidateItems = shouldUseFlatFilteredItems ? flatFilteredItems : valuesRef.current; + const candidateItems = store.state.itemValues; const storeActiveIndex = store.state.activeIndex; if (storeActiveIndex == null) { @@ -860,6 +896,12 @@ export function AriaCombobox( return; } + // Item metadata is registered after render. Keep the pending highlight until + // the visible registry catches up with the already-derived filtered items. + if (candidateItems.length === 0 && flatFilteredItemsLength > 0) { + return; + } + if (storeActiveIndex >= candidateItems.length) { if (lastHighlightRef.current !== INITIAL_LAST_HIGHLIGHT) { lastHighlightRef.current = INITIAL_LAST_HIGHLIGHT; @@ -873,6 +915,11 @@ export function AriaCombobox( } const itemValue = candidateItems[storeActiveIndex]; + // Manual indexes and virtualized items can leave holes in the registry. + if (itemValue === undefined) { + return; + } + const previouslyHighlightedItemValue = lastHighlightRef.current.value; const isSameItem = previouslyHighlightedItemValue !== NO_ACTIVE_VALUE && @@ -889,16 +936,7 @@ export function AriaCombobox( createGenericEventDetails(REASONS.none, undefined, { index: storeActiveIndex }), ); } - }, [ - activeIndex, - autoHighlightMode, - hasFilteredItemsProp, - hasItems, - flatFilteredItems, - inline, - open, - store, - ]); + }, [activeIndex, autoHighlightMode, flatFilteredItemsLength, inline, itemValues, open, store]); useIsoLayoutEffect(() => { if (selectionMode === 'none') { @@ -913,10 +951,10 @@ export function AriaCombobox( // Ensures that the active index is not set to 0 when the list is empty. // This avoids needing to press ArrowDown twice under certain conditions. React.useEffect(() => { - if (hasItems && autoHighlightMode && flatFilteredItems.length === 0) { + if (hasItems && autoHighlightMode && flatFilteredItemsLength === 0) { setIndices({ activeIndex: null }); } - }, [hasItems, autoHighlightMode, flatFilteredItems.length, setIndices]); + }, [hasItems, autoHighlightMode, flatFilteredItemsLength, setIndices]); useValueChanged(query, () => { if (!open || query === '' || query === String(initialDefaultInputValue)) { @@ -940,7 +978,7 @@ export function AriaCombobox( } if (single && !hasInputValue && !inputInsidePopup) { - const nextInputValue = stringifyAsLabel(selectedValue, itemToStringLabel); + const nextInputValue = selectedLabelString; if (inputValue !== nextInputValue) { setInputValue(nextInputValue, createChangeEventDetails(REASONS.none)); @@ -968,7 +1006,7 @@ export function AriaCombobox( return; } - const nextInputValue = stringifyAsLabel(selectedValue, itemToStringLabel); + const nextInputValue = selectedLabelString; if (inputValue !== nextInputValue) { setInputValue(nextInputValue, createChangeEventDetails(REASONS.none)); @@ -1058,7 +1096,7 @@ export function AriaCombobox( id, listRef, activeIndex, - selectedIndex, + selectedIndex: listNavigationSelectedIndex, virtual: true, loopFocus, allowEscape: loopFocus && !autoHighlightMode, @@ -1267,23 +1305,23 @@ export function AriaCombobox( const nextValue = event.currentTarget.value; const details = createChangeEventDetails(REASONS.none, event.nativeEvent); - function handleChange() { - // Browser autofill only writes a single scalar value. - if (multiple) { - return; - } + // Browser autofill only writes a single scalar value. + if (multiple) { + return; + } - if (selectionMode === 'none') { - setDirty(nextValue !== validityData.initialValue); - setInputValue(nextValue, details); + if (selectionMode === 'none') { + setDirty(nextValue !== validityData.initialValue); + setInputValue(nextValue, details); - if (shouldValidateOnChange()) { - validation.commit(nextValue); - } - return; + if (shouldValidateOnChange()) { + validation.commit(nextValue); } + return; + } - const matchingValue = valuesRef.current.find((v) => { + function handleChange() { + const matchingIndex = valuesRef.current.findIndex((v, index) => { // Try matching by value first (e.g., "US" for country code) const candidateValue = stringifyAsValue(v, itemToStringValue); if (candidateValue.toLowerCase() === nextValue.toLowerCase()) { @@ -1291,14 +1329,20 @@ export function AriaCombobox( } // Also try matching by label for browser autofill compatibility // (browsers autofill with displayed text like "United States", not the underlying value) - const candidateLabel = stringifyAsLabel(v, itemToStringLabel); + const labelSource = items ? flatFilteredItems[index] : v; + const candidateLabel = stringifyComboboxItemLabel(labelSource, itemToStringLabel); if (candidateLabel.toLowerCase() === nextValue.toLowerCase()) { return true; } return false; }); - if (matchingValue != null) { + if (matchingIndex !== -1) { + const registeredValues = store.state.itemValues; + const matchingValue = + registeredValues[matchingIndex] !== undefined + ? registeredValues[matchingIndex] + : valuesRef.current[matchingIndex]; setDirty(matchingValue !== validityData.initialValue); setSelectedValue?.(matchingValue, details); @@ -1306,14 +1350,15 @@ export function AriaCombobox( validation.commit(matchingValue); } } + + store.set('forceMounted', false); } + forceMount(); if (items) { - handleChange(); - } else { - forceMount(); - queueMicrotask(handleChange); + store.set('forceMounted', true); } + queueMicrotask(handleChange); }, })} id={id && hiddenInputName == null ? `${id}-hidden-input` : undefined} @@ -1347,6 +1392,23 @@ export function AriaCombobox( ); } +function resolveLabelString( + value: any, + items: readonly any[] | readonly Group[] | undefined, + itemToStringLabel: ((item: any) => string) | undefined, + isItemEqualToValue: (itemValue: any, selectedValue: any) => boolean, +) { + const label = resolveSelectedLabel(value, items, itemToStringLabel, isItemEqualToValue); + + if (typeof label === 'string' || typeof label === 'number') { + return String(label); + } + + return label == null || typeof label === 'boolean' + ? '' + : stringifyAsLabel(value, typeof value === 'object' ? itemToStringLabel : undefined); +} + type SelectionMode = 'single' | 'multiple' | 'none'; type ComboboxItemValueType = Mode extends 'multiple' diff --git a/packages/react/src/combobox/root/ComboboxRoot.test.tsx b/packages/react/src/combobox/root/ComboboxRoot.test.tsx index 95bea7ed0b8..4c82964ffa9 100644 --- a/packages/react/src/combobox/root/ComboboxRoot.test.tsx +++ b/packages/react/src/combobox/root/ComboboxRoot.test.tsx @@ -9,7 +9,7 @@ import { ignoreActWarnings, reactMajor, } from '@mui/internal-test-utils'; -import { createRenderer, isJSDOM, popupConformanceTests } from '#test-utils'; +import { createRenderer, isJSDOM, popupConformanceTests, wait } from '#test-utils'; import { Combobox } from '@base-ui/react/combobox'; import { Dialog } from '@base-ui/react/dialog'; import { Field } from '@base-ui/react/field'; @@ -877,6 +877,513 @@ describe('', () => { }); }); + it('derives selectedIndex on first open after a programmatic value change without the items prop', async () => { + function App() { + const [value, setValue] = React.useState(null); + + return ( + + + + + + + + + + apple + banana + cherry + + + + + + + ); + } + + const { user } = await render(); + + expect(screen.queryByRole('listbox')).toBe(null); + + await user.click(screen.getByTestId('set-external')); + + expect(screen.getByTestId('selected-index').textContent).toBe('null'); + expect(screen.queryByRole('listbox')).toBe(null); + + await user.click(screen.getByTestId('input')); + expect(await screen.findByRole('listbox')).not.toBe(null); + + await waitFor(() => { + expect(screen.getByTestId('selected-index').textContent).toBe('1'); + }); + + expect(screen.getByRole('option', { name: 'banana' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('highlights the selected item on open after a programmatic value change without the items prop', async () => { + function App() { + const [value, setValue] = React.useState(null); + + return ( + + + + + + + + + apple + banana + cherry + + + + + + + ); + } + + const { user } = await render(); + + await user.click(screen.getByTestId('set-external')); + await user.click(screen.getByTestId('input')); + + const input = screen.getByRole('combobox'); + const banana = await screen.findByRole('option', { name: 'banana' }); + + await waitFor(() => { + expect(banana).toHaveAttribute('data-highlighted'); + expect(input).toHaveAttribute('aria-activedescendant', banana.id); + }); + }); + + it('does not highlight a removed selected item on reopen without the items prop', async () => { + function App() { + const [showCherry, setShowCherry] = React.useState(true); + + return ( + + + + + + + + + apple + banana + {showCherry && cherry} + + + + + + + ); + } + + const { user } = await render(); + + const input = screen.getByTestId('input'); + + await user.click(input); + const cherry = await screen.findByRole('option', { name: 'cherry' }); + + await waitFor(() => { + expect(input).toHaveAttribute('aria-activedescendant', cherry.id); + }); + + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(screen.queryByRole('listbox')).toBe(null); + }); + + await user.click(screen.getByTestId('remove-cherry')); + await user.click(input); + await screen.findByRole('option', { name: 'apple' }); + await flushMicrotasks(); + + expect(input).not.toHaveAttribute('aria-activedescendant'); + }); + + it('preserves the inline item registry when an item unmounts without the items prop', async () => { + function App() { + const [showCherry, setShowCherry] = React.useState(true); + + return ( + + + + + + + apple + banana + {showCherry && cherry} + + + + ); + } + + const { user } = await render(); + + await waitFor(() => { + expect(screen.getByTestId('selected-index').textContent).toBe('1'); + }); + + await user.click(screen.getByTestId('remove-cherry')); + + await waitFor(() => { + expect(screen.getByTestId('selected-index').textContent).toBe('1'); + expect(screen.getByRole('option', { name: 'banana' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + }); + + it.skipIf(isJSDOM)( + 'scrolls the selected item into view on open without the items prop', + async () => { + const items = Array.from({ length: 30 }, (_, index) => `item-${index}`); + + function App() { + const [value, setValue] = React.useState(null); + + return ( + + + + + + + + + {items.map((item) => ( + + {item} + + ))} + + + + + + + ); + } + + const { user } = await render(); + + await user.click(screen.getByTestId('set-external')); + await user.click(screen.getByTestId('input')); + + const popup = await screen.findByTestId('popup'); + const option = await screen.findByRole('option', { name: 'item-25' }); + + await waitFor(() => { + expect(option).toHaveAttribute('data-highlighted'); + expect(popup.scrollTop).toBeGreaterThan(0); + + const popupRect = popup.getBoundingClientRect(); + const optionRect = option.getBoundingClientRect(); + expect(optionRect.top).toBeGreaterThanOrEqual(popupRect.top); + expect(optionRect.bottom).toBeLessThanOrEqual(popupRect.bottom); + }); + }, + ); + + it.skipIf(isJSDOM)( + 'scrolls the selected item into view when opening from the trigger without the items prop', + async () => { + const items = Array.from({ length: 30 }, (_, index) => `item-${index}`); + + function App() { + const [value, setValue] = React.useState(null); + + return ( + + + + + Open + + + + + {items.map((item) => ( + + {item} + + ))} + + + + + + + ); + } + + const { user } = await render(); + + await user.click(screen.getByTestId('set-external')); + await user.click(screen.getByTestId('trigger')); + + const popup = await screen.findByTestId('popup'); + const option = await screen.findByRole('option', { name: 'item-25' }); + + await waitFor(() => { + expect(option).toHaveAttribute('data-highlighted'); + expect(popup.scrollTop).toBeGreaterThan(0); + + const popupRect = popup.getBoundingClientRect(); + const optionRect = option.getBoundingClientRect(); + expect(optionRect.top).toBeGreaterThanOrEqual(popupRect.top); + expect(optionRect.bottom).toBeLessThanOrEqual(popupRect.bottom); + }); + }, + ); + + it('re-syncs selectedIndex after an external controlled update when closing without the items prop', async () => { + function App() { + const [value, setValue] = React.useState('apple'); + + return ( + + + + + + + + + apple + banana + cherry + + + + + + ); + } + + const { user } = await render(); + + const input = screen.getByTestId('input'); + await user.click(input); + expect(await screen.findByRole('listbox')).not.toBe(null); + + await waitFor(() => { + expect(screen.getByTestId('selected-index').textContent).toBe('0'); + }); + + await user.click(screen.getByTestId('set-external')); + await waitFor(() => { + expect(screen.queryByRole('listbox')).toBe(null); + expect(screen.getByTestId('selected-index').textContent).toBe('2'); + }); + + await user.click(input); + expect(await screen.findByRole('listbox')).not.toBe(null); + + await waitFor(() => { + expect(screen.getByTestId('selected-index').textContent).toBe('2'); + }); + }); + + it('resets selectedIndex when clearing all selections while open without the items prop', async () => { + function App() { + const [value, setValue] = React.useState(['apple', 'banana']); + + return ( + + + + + + + + + apple + banana + cherry + + + + + + ); + } + + const { user } = await render(); + + await user.click(screen.getByTestId('input')); + expect(await screen.findByRole('listbox')).not.toBe(null); + expect(screen.getByTestId('selected-index').textContent).toBe('1'); + + await user.click(screen.getByTestId('clear')); + + await waitFor(() => { + expect(screen.getByTestId('selected-index').textContent).toBe('null'); + }); + }); + + it('starts keyboard navigation from the filtered items after filtering out the selected item', async () => { + const items = ['apple', 'banana', 'cherry']; + + const { user } = await render( + + + + + + + {(item: string) => ( + + {item} + + )} + + + + + , + ); + + const input = screen.getByTestId('input'); + + await user.click(input); + await user.type(input, 'ba'); + const banana = await screen.findByRole('option', { name: 'banana' }); + + await user.keyboard('{ArrowDown}'); + + await waitFor(() => { + expect(banana).toHaveAttribute('data-highlighted'); + }); + await waitFor(() => { + expect(input).toHaveAttribute('aria-activedescendant', banana.id); + }); + }); + + it('updates derived indices when an item unmounts while closed but kept mounted', async () => { + function App() { + const [showCherry, setShowCherry] = React.useState(true); + + return ( + + + + + + + + + + apple + banana + {showCherry && cherry} + + + + + + + ); + } + + const { user } = await render(); + + const input = screen.getByTestId('input'); + + await user.click(input); + await screen.findByRole('option', { name: 'cherry' }); + + await waitFor(() => { + expect(screen.getByTestId('selected-index').textContent).toBe('2'); + }); + + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(input).toHaveAttribute('aria-expanded', 'false'); + }); + + await user.click(screen.getByTestId('remove-cherry')); + await waitFor(() => { + expect(screen.getByTestId('selected-index').textContent).toBe('null'); + }); + + await user.click(input); + + await screen.findByRole('option', { name: 'apple' }); + + await waitFor(() => { + expect(input).not.toHaveAttribute('aria-activedescendant'); + }); + }); + it('should create multiple hidden inputs for form submission', async () => { const items = ['a', 'b', 'c']; await render( @@ -1043,31 +1550,70 @@ describe('', () => { const input = screen.getByTestId('input'); await user.click(input); - await waitFor(() => expect(screen.getByRole('listbox')).not.toBe(null)); + await waitFor(() => expect(screen.getByRole('listbox')).not.toBe(null)); + + // Highlight first item and select it + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); + + expect(screen.queryByRole('listbox')).toBe(null); + expect(input).toHaveValue('apple'); + }); + + it('Enter selects with manual indices provided to items', async () => { + const items = ['apple', 'banana', 'cherry']; + + const { user } = await render( + + + + + + + {(item: string, index: number) => ( + + {item} + + )} + + + + + , + ); + + const input = screen.getByTestId('input'); + + await user.click(input); + await waitFor(() => { + expect(screen.getByRole('listbox')).not.toBe(null); + }); - // Highlight first item and select it + await user.type(input, 'c'); // filter to "cherry" await user.keyboard('{ArrowDown}'); await user.keyboard('{Enter}'); - expect(screen.queryByRole('listbox')).toBe(null); - expect(input).toHaveValue('apple'); + await waitFor(() => { + expect(input).toHaveValue('cherry'); + }); }); - it('Enter selects with manual indices provided to items', async () => { + it('reports highlighted values with manual indices without the items prop', async () => { + const onItemHighlighted = vi.fn(); const items = ['apple', 'banana', 'cherry']; const { user } = await render( - + - {(item: string, index: number) => ( + {items.map((item, index) => ( {item} - )} + ))} @@ -1082,12 +1628,15 @@ describe('', () => { expect(screen.getByRole('listbox')).not.toBe(null); }); - await user.type(input, 'c'); // filter to "cherry" await user.keyboard('{ArrowDown}'); - await user.keyboard('{Enter}'); + const apple = screen.getByRole('option', { name: 'apple' }); await waitFor(() => { - expect(input).toHaveValue('cherry'); + expect(onItemHighlighted).toHaveBeenCalledWith( + 'apple', + expect.objectContaining({ index: 0, reason: 'keyboard' }), + ); + expect(input).toHaveAttribute('aria-activedescendant', apple.id); }); }); @@ -1853,9 +2402,245 @@ describe('', () => { describe('prop: disabled', () => { it('should render disabled state on all interactive components', async () => { const { user } = await render( - + + + Open + + + + + + a + + + b + + + + + + , + ); + + const input = screen.getByTestId('input'); + const trigger = screen.getByTestId('trigger'); + + expect(input).toHaveAttribute('disabled'); + expect(trigger).toHaveAttribute('disabled'); + + // Verify interactions are disabled + await user.click(trigger); + expect(screen.queryByRole('listbox')).toBe(null); + }); + + it('should not open popup when disabled', async () => { + const { user } = await render( + + + Open + + + + + a + b + + + + + , + ); + + const input = screen.getByTestId('input'); + const trigger = screen.getByTestId('trigger'); + + await user.click(input); + expect(screen.queryByRole('listbox')).toBe(null); + + await user.click(trigger); + expect(screen.queryByRole('listbox')).toBe(null); + }); + + it('should prevent keyboard interactions when disabled', async () => { + const { user } = await render( + + + + + + + a + b + + + + + , + ); + + const input = screen.getByTestId('input'); + + await user.type(input, 'a'); + expect(screen.queryByRole('listbox')).toBe(null); + }); + + it('should set disabled attribute on hidden input', async () => { + await render( + + + + + + + a + + + + + , + ); + + const hiddenInput = screen.getByRole('textbox', { hidden: true }); + expect(hiddenInput).toHaveAttribute('disabled'); + }); + }); + + describe('prop: required', () => { + it('does not mark the hidden input as required when selection exists in multiple mode', async () => { + await render( + + + , + ); + + const hiddenInput = screen.getByRole('textbox', { hidden: true }); + expect(hiddenInput).not.toBe(null); + expect(hiddenInput).not.toHaveAttribute('required'); + }); + + it('keeps the hidden input required when no selection exists in multiple mode', async () => { + await render( + + + , + ); + + const hiddenInput = screen.getByRole('textbox', { hidden: true }); + expect(hiddenInput).not.toBe(null); + expect(hiddenInput).toHaveAttribute('required'); + }); + }); + + describe('prop: readOnly', () => { + it('should render readOnly state on the input and disable interactions', async () => { + const { user } = await render( + + + Open + + + + + + a + + + b + + + + + + , + ); + + const input = screen.getByTestId('input'); + const trigger = screen.getByTestId('trigger'); + + expect(input).toHaveAttribute('aria-readonly', 'true'); + expect(input).toHaveAttribute('readonly'); + + // Verify interactions are disabled + await user.click(trigger); + expect(screen.queryByRole('listbox')).toBe(null); + }); + + it('should not open popup when readOnly', async () => { + const { user } = await render( + + + Open + + + + + a + b + + + + + , + ); + + const input = screen.getByTestId('input'); + const trigger = screen.getByTestId('trigger'); + + await user.click(input); + expect(screen.queryByRole('listbox')).toBe(null); + + await user.click(trigger); + expect(screen.queryByRole('listbox')).toBe(null); + }); + + it('should prevent keyboard interactions when readOnly', async () => { + const { user } = await render( + + + + + + + a + b + + + + + , + ); + + const input = screen.getByTestId('input'); + + await user.type(input, 'a'); + expect(screen.queryByRole('listbox')).toBe(null); + }); + + it('should set readOnly attribute on hidden input', async () => { + await render( + + + + + + + a + + + + + , + ); + + const hiddenInput = screen.getByRole('textbox', { hidden: true }); + expect(hiddenInput).toHaveAttribute('readonly'); + }); + + it('should prevent value changes when readOnly with items', async () => { + const handleValueChange = vi.fn(); + const { user } = await render( + - Open @@ -1873,28 +2658,38 @@ describe('', () => { , ); - const input = screen.getByTestId('input'); - const trigger = screen.getByTestId('trigger'); - - expect(input).toHaveAttribute('disabled'); - expect(trigger).toHaveAttribute('disabled'); + const itemA = screen.getByTestId('item-a'); + await user.click(itemA); - // Verify interactions are disabled - await user.click(trigger); - expect(screen.queryByRole('listbox')).toBe(null); + expect(handleValueChange.mock.calls.length).toBe(0); }); + }); - it('should not open popup when disabled', async () => { + describe('prop: itemToStringLabel', () => { + const items = [ + { country: 'United States', code: 'US' }, + { country: 'Canada', code: 'CA' }, + { country: 'Australia', code: 'AU' }, + ]; + + it('uses itemToStringLabel for input value synchronization', async () => { const { user } = await render( - - - Open + item.country} + itemToStringValue={(item: (typeof items)[number]) => item.code} + defaultOpen + > + - a - b + {(item: { country: string; code: string }) => ( + + {item.country} + + )} @@ -1902,26 +2697,56 @@ describe('', () => { , ); - const input = screen.getByTestId('input'); - const trigger = screen.getByTestId('trigger'); + const input = screen.getByRole('combobox'); + await user.click(screen.getByText('Canada')); + expect(input).toHaveValue('Canada'); + }); - await user.click(input); - expect(screen.queryByRole('listbox')).toBe(null); + it('shows the label for a controlled object value not in items', async () => { + const value = { country: 'Japan', code: 'JP' }; - await user.click(trigger); - expect(screen.queryByRole('listbox')).toBe(null); + await render( + item.country} + itemToStringValue={(item) => item.code} + > + + , + ); + + const input = screen.getByRole('combobox'); + expect(input).toHaveValue('Japan'); }); + }); - it('should prevent keyboard interactions when disabled', async () => { - const { user } = await render( - - + describe('prop: itemToStringValue', () => { + const items = [ + { country: 'United States', code: 'US' }, + { country: 'Canada', code: 'CA' }, + { country: 'Australia', code: 'AU' }, + ]; + + it('uses itemToStringValue for form submission', async () => { + await render( + item.country} + itemToStringValue={(item) => item.code} + defaultValue={items[0]} + > + - a - b + {(item: string) => ( + + {item} + + )} @@ -1929,21 +2754,32 @@ describe('', () => { , ); - const input = screen.getByTestId('input'); - - await user.type(input, 'a'); - expect(screen.queryByRole('listbox')).toBe(null); + const hiddenInput = screen.getByDisplayValue('US'); // input[name="country"] + expect(hiddenInput.tagName).toBe('INPUT'); + expect(hiddenInput).toHaveAttribute('name', 'country'); }); - it('should set disabled attribute on hidden input', async () => { + it('uses itemToStringValue for multiple selection form submission', async () => { + const values = [items[0], items[1]]; await render( - + item.country} + itemToStringValue={(item) => item.code} + multiple + defaultValue={values} + > - a + {(item: string) => ( + + {item} + + )} @@ -1951,53 +2787,173 @@ describe('', () => { , ); - const hiddenInput = screen.getByRole('textbox', { hidden: true }); - expect(hiddenInput).toHaveAttribute('disabled'); + values.forEach((value) => { + const input = screen.getByDisplayValue(value.code); + expect(input.tagName).toBe('INPUT'); + expect(input).toHaveAttribute('name', 'countries'); + }); }); }); - describe('prop: required', () => { - it('does not mark the hidden input as required when selection exists in multiple mode', async () => { + describe('initial input value derivation', () => { + it('derives input from defaultValue on first mount when unspecified', async () => { await render( - + , ); - const hiddenInput = screen.getByRole('textbox', { hidden: true }); - expect(hiddenInput).not.toBe(null); - expect(hiddenInput).not.toHaveAttribute('required'); + expect(screen.getByRole('combobox')).toHaveValue('apple'); }); - it('keeps the hidden input required when no selection exists in multiple mode', async () => { + it('derives input from defaultValue on first mount with items prop', async () => { + const items = [{ value: 'apple', label: 'Apple' }]; await render( - + , ); - const hiddenInput = screen.getByRole('textbox', { hidden: true }); - expect(hiddenInput).not.toBe(null); - expect(hiddenInput).toHaveAttribute('required'); + expect(screen.getByRole('combobox')).toHaveValue('Apple'); + }); + + it('derives input from controlled value on first mount when unspecified', async () => { + await render( + + + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('banana'); + }); + + it('defaultInputValue overrides derivation when provided', async () => { + await render( + + + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('x'); + }); + + it('inputValue overrides derivation when provided', async () => { + await render( + + + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('x'); + }); + + it('multiple mode initial input remains empty', async () => { + const items = [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ]; + await render( + + + , + ); + + const input = screen.getAllByRole('combobox').find((element) => element.tagName === 'INPUT'); + + expect(input).toHaveValue(''); + }); + + it('does not set input value for input-inside-popup pattern', async () => { + await render( + + Trigger + + + + + + + + , + ); + + const input = screen.getAllByRole('combobox').find((element) => element.tagName === 'INPUT'); + + expect(input).toHaveValue(''); }); }); - describe('prop: readOnly', () => { - it('should render readOnly state on the input and disable interactions', async () => { - const { user } = await render( - - - Open + describe('primitive selected values with object items', () => { + interface FruitItem { + value: string; + label: string; + } + + const items: FruitItem[] = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + ]; + + function FruitList() { + return ( + + + + + {(item: FruitItem) => ( + + {item.label} + + )} + + + + + ); + } + + it('derives the input and value text from the matching item label', async () => { + await render( + + + + + + + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('Banana'); + expect(screen.getByTestId('value')).toHaveTextContent('Banana'); + }); + + it('prefers the matching item label over itemToStringLabel for a primitive value', async () => { + const itemsWithShortLabel = [ + { value: 'apple', label: 'Apple', shortLabel: 'A' }, + { value: 'banana', label: 'Banana', shortLabel: 'B' }, + ]; + const itemToStringLabel = vi.fn((item: string) => item.toUpperCase()); + + await render( + + items={itemsWithShortLabel} + defaultValue="banana" + itemToStringLabel={itemToStringLabel} + > + + + + - - a - - - b - + {(item: (typeof itemsWithShortLabel)[number]) => ( + + {item.label} + + )} @@ -2005,55 +2961,55 @@ describe('', () => { , ); - const input = screen.getByTestId('input'); - const trigger = screen.getByTestId('trigger'); - - expect(input).toHaveAttribute('aria-readonly', 'true'); - expect(input).toHaveAttribute('readonly'); - - // Verify interactions are disabled - await user.click(trigger); - expect(screen.queryByRole('listbox')).toBe(null); + expect(screen.getByRole('combobox')).toHaveValue('Banana'); + expect(screen.getByTestId('value')).toHaveTextContent('Banana'); + expect(itemToStringLabel).not.toHaveBeenCalled(); }); - it('should not open popup when readOnly', async () => { + it('does not call a primitive itemToStringLabel with object items while filtering', async () => { + const itemToStringLabel = vi.fn((item: string) => item.toUpperCase()); + const { user } = await render( - - - Open - - - - - a - b - - - - + items={items} itemToStringLabel={itemToStringLabel}> + + , ); - const input = screen.getByTestId('input'); - const trigger = screen.getByTestId('trigger'); + await user.type(screen.getByRole('combobox'), 'ba'); - await user.click(input); - expect(screen.queryByRole('listbox')).toBe(null); + expect(await screen.findByRole('option', { name: 'Banana' })).not.toBe(null); + expect(itemToStringLabel).not.toHaveBeenCalled(); + }); - await user.click(trigger); - expect(screen.queryByRole('listbox')).toBe(null); + it('derives the selected index from item value properties while closed', async () => { + await render( + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('selected-index')).toHaveTextContent('1'); + }); }); - it('should prevent keyboard interactions when readOnly', async () => { - const { user } = await render( - - + it('derives the selected index from rendered object values while closed', async () => { + await render( + + + - a - b + {(item: FruitItem) => ( + + {item.label} + + )} @@ -2061,21 +3017,85 @@ describe('', () => { , ); - const input = screen.getByTestId('input'); + await waitFor(() => { + expect(screen.getByTestId('selected-index')).toHaveTextContent('1'); + }); + }); - await user.type(input, 'a'); - expect(screen.queryByRole('listbox')).toBe(null); + it('re-derives controlled primitive values from matching item labels', async () => { + const { setProps } = await render( + + + + + + + + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('Apple'); + expect(screen.getByTestId('value')).toHaveTextContent('Apple'); + await waitFor(() => { + expect(screen.getByTestId('selected-index')).toHaveTextContent('0'); + }); + + await setProps({ value: 'banana' }); + + expect(screen.getByRole('combobox')).toHaveValue('Banana'); + expect(screen.getByTestId('value')).toHaveTextContent('Banana'); + await waitFor(() => { + expect(screen.getByTestId('selected-index')).toHaveTextContent('1'); + }); }); - it('should set readOnly attribute on hidden input', async () => { + it('supports custom equality against primitive item values', async () => { + const isItemEqualToValue = vi.fn((itemValue: string, value: string) => { + return itemValue.toLowerCase() === value.toLowerCase(); + }); + await render( - + + + + + , + ); + + expect(screen.getByRole('combobox')).toHaveValue('Banana'); + await waitFor(() => { + expect(screen.getByTestId('selected-index')).toHaveTextContent('1'); + }); + expect( + isItemEqualToValue.mock.calls.every( + ([itemValue, value]) => typeof itemValue === 'string' && typeof value === 'string', + ), + ).toBe(true); + }); + + it('supports custom equality against object item value properties', async () => { + const objectValueItems = [ + { value: { code: 'apple' }, label: 'Apple' }, + { value: { code: 'banana' }, label: 'Banana' }, + ]; + + await render( + itemValue.code === value.code} + > + - a + {(item: (typeof objectValueItems)[number]) => ( + + {item.label} + + )} @@ -2083,62 +3103,76 @@ describe('', () => { , ); - const hiddenInput = screen.getByRole('textbox', { hidden: true }); - expect(hiddenInput).toHaveAttribute('readonly'); + expect(screen.getByRole('combobox')).toHaveValue('Banana'); + await waitFor(() => { + expect(screen.getByTestId('selected-index')).toHaveTextContent('1'); + }); }); - it('should prevent value changes when readOnly with items', async () => { - const handleValueChange = vi.fn(); + it('uses inferred primitive values for closed trigger typeahead before a selection', async () => { + const onValueChange = vi.fn(); const { user } = await render( - - - - - - - - a - - - b - - - - - + + + + + , ); - const itemA = screen.getByTestId('item-a'); - await user.click(itemA); + await act(async () => { + screen.getByTestId('trigger').focus(); + await wait(0); + }); + await user.keyboard('b'); - expect(handleValueChange.mock.calls.length).toBe(0); + await waitFor(() => { + expect(onValueChange).toHaveBeenCalledWith( + 'banana', + expect.objectContaining({ reason: REASONS.none }), + ); + }); }); - }); - describe('prop: itemToStringLabel', () => { - const items = [ - { country: 'United States', code: 'US' }, - { country: 'Canada', code: 'CA' }, - { country: 'Australia', code: 'AU' }, - ]; + it('uses inferred primitive values for closed trigger typeahead after a primitive selection', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + + + + + + , + ); - it('uses itemToStringLabel for input value synchronization', async () => { + await act(async () => { + screen.getByTestId('trigger').focus(); + await wait(0); + }); + await user.keyboard('b'); + + await waitFor(() => { + expect(onValueChange).toHaveBeenCalledWith( + 'banana', + expect.objectContaining({ reason: REASONS.none }), + ); + }); + }); + + it('does not force-mount rendered items for closed trigger typeahead', async () => { + const onValueChange = vi.fn(); const { user } = await render( - item.country} - itemToStringValue={(item: (typeof items)[number]) => item.code} - defaultOpen - > - + items={items} onValueChange={onValueChange}> + + + - {(item: { country: string; code: string }) => ( - - {item.country} + {(item: FruitItem) => ( + + {item.label} )} @@ -2148,54 +3182,36 @@ describe('', () => { , ); - const input = screen.getByRole('combobox'); - await user.click(screen.getByText('Canada')); - expect(input).toHaveValue('Canada'); - }); - - it('shows the label for a controlled object value not in items', async () => { - const value = { country: 'Japan', code: 'JP' }; + await act(async () => { + screen.getByTestId('trigger').focus(); + await wait(0); + }); + expect(screen.queryByRole('listbox', { hidden: true })).toBe(null); - await render( - item.country} - itemToStringValue={(item) => item.code} - > - - , - ); + await user.keyboard('b'); - const input = screen.getByRole('combobox'); - expect(input).toHaveValue('Japan'); + await waitFor(() => { + expect(onValueChange).toHaveBeenCalledWith( + 'banana', + expect.objectContaining({ reason: REASONS.none }), + ); + }); }); - }); - - describe('prop: itemToStringValue', () => { - const items = [ - { country: 'United States', code: 'US' }, - { country: 'Canada', code: 'CA' }, - { country: 'Australia', code: 'AU' }, - ]; - it('uses itemToStringValue for form submission', async () => { - await render( - item.country} - itemToStringValue={(item) => item.code} - defaultValue={items[0]} - > - + it('does not force-mount manually indexed items for closed trigger typeahead', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + items={items} onValueChange={onValueChange}> + + + - {(item: string) => ( - - {item} + {(item: FruitItem, index: number) => ( + + {item.label} )} @@ -2205,30 +3221,68 @@ describe('', () => { , ); - const hiddenInput = screen.getByDisplayValue('US'); // input[name="country"] - expect(hiddenInput.tagName).toBe('INPUT'); - expect(hiddenInput).toHaveAttribute('name', 'country'); + await act(async () => { + screen.getByTestId('trigger').focus(); + await wait(0); + }); + expect(screen.queryByRole('listbox', { hidden: true })).toBe(null); + + await user.keyboard('b'); + + await waitFor(() => { + expect(onValueChange).toHaveBeenCalledWith( + 'banana', + expect.objectContaining({ reason: REASONS.none }), + ); + }); }); - it('uses itemToStringValue for multiple selection form submission', async () => { - const values = [items[0], items[1]]; + it('syncs the last selected index for primitive arrays with object items', async () => { await render( - item.country} - itemToStringValue={(item) => item.code} - multiple - defaultValue={values} - > + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('selected-index')).toHaveTextContent('2'); + }); + }); + + it('selects inferred primitive values from open keyboard navigation before a selection', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + + + + , + ); + + const input = screen.getByRole('combobox'); + await user.click(input); + await screen.findByRole('listbox'); + await user.keyboard('{ArrowDown}{Enter}'); + + expect(onValueChange).toHaveBeenCalledWith( + 'apple', + expect.objectContaining({ reason: REASONS.itemPress }), + ); + }); + + it('selects rendered object values from open keyboard navigation', async () => { + const onValueChange = vi.fn(); + const { user } = await render( + - {(item: string) => ( - - {item} + {(item: FruitItem) => ( + + {item.label} )} @@ -2238,99 +3292,154 @@ describe('', () => { , ); - values.forEach((value) => { - const input = screen.getByDisplayValue(value.code); - expect(input.tagName).toBe('INPUT'); - expect(input).toHaveAttribute('name', 'countries'); - }); - }); - }); + const input = screen.getByRole('combobox'); + await user.click(input); + await screen.findByRole('listbox'); + await user.keyboard('{ArrowDown}{Enter}'); - describe('initial input value derivation', () => { - it('derives input from defaultValue on first mount when unspecified', async () => { - await render( - - - , + expect(onValueChange).toHaveBeenCalledWith( + items[0], + expect.objectContaining({ reason: REASONS.itemPress }), ); - - expect(screen.getByRole('combobox')).toHaveValue('apple'); }); - it('derives input from defaultValue on first mount with items prop', async () => { - const items = [{ value: 'apple', label: 'Apple' }]; + it('uses inferred primitive values when browser autofill matches an item label', async () => { + const onValueChange = vi.fn(); + await render( - + + , ); - expect(screen.getByRole('combobox')).toHaveValue('Apple'); - }); - - it('derives input from controlled value on first mount when unspecified', async () => { - await render( - - - , + fireEvent.change( + screen.getAllByDisplayValue('').find((el) => el.getAttribute('name') === 'fruit')!, + { target: { value: 'Banana' } }, ); + await flushMicrotasks(); - expect(screen.getByRole('combobox')).toHaveValue('banana'); + expect(onValueChange).toHaveBeenCalledWith( + 'banana', + expect.objectContaining({ reason: REASONS.none }), + ); }); - it('defaultInputValue overrides derivation when provided', async () => { + it('uses rendered object values when browser autofill matches an item label', async () => { + const onValueChange = vi.fn(); + await render( - + + + + + + {(item: FruitItem) => ( + + {item.label} + + )} + + + + , ); - expect(screen.getByRole('combobox')).toHaveValue('x'); + fireEvent.change( + screen.getAllByDisplayValue('').find((el) => el.getAttribute('name') === 'fruit')!, + { target: { value: 'Banana' } }, + ); + await flushMicrotasks(); + + expect(onValueChange).toHaveBeenCalledWith( + items[1], + expect.objectContaining({ reason: REASONS.none }), + ); }); - it('inputValue overrides derivation when provided', async () => { + it('uses inferred primitive values for virtualized item indexes before a selection', async () => { await render( - + + + {(item: FruitItem) => ( + + {item.label} + + )} + , ); - expect(screen.getByRole('combobox')).toHaveValue('x'); + expect(screen.getByRole('option', { name: 'Banana' }).id).not.toContain('--1'); }); + }); - it('multiple mode initial input remains empty', async () => { - const items = [ - { value: 'a', label: 'A' }, - { value: 'b', label: 'B' }, - ]; - await render( - - - , - ); + it('filters label-only object items by their label', async () => { + const items = [ + { id: 'fruit-apple', label: 'Apple', group: 'Fruits' }, + { id: 'fruit-banana', label: 'Banana', group: 'Fruits' }, + ]; - const input = screen.getAllByRole('combobox').find((element) => element.tagName === 'INPUT'); + const { user } = await render( + + + + + + + {(item: (typeof items)[number]) => ( + + {item.label} + + )} + + + + + , + ); - expect(input).toHaveValue(''); + await user.type(screen.getByRole('combobox'), 'fruit'); + + expect(screen.queryByRole('option', { name: 'Apple' })).toBe(null); + expect(screen.queryByRole('option', { name: 'Banana' })).toBe(null); + }); + + it('unmounts a force-mounted closed popup without an items prop after trigger blur', async () => { + await render( + + + + + + + + + Apple + Banana + + + + + , + ); + + await act(async () => { + screen.getByTestId('trigger').focus(); + await wait(0); }); - it('does not set input value for input-inside-popup pattern', async () => { - await render( - - Trigger - - - - - - - - , - ); + expect(screen.getByRole('listbox', { hidden: true })).not.toBe(null); - const input = screen.getAllByRole('combobox').find((element) => element.tagName === 'INPUT'); + act(() => { + screen.getByTestId('trigger').blur(); + }); - expect(input).toHaveValue(''); + await waitFor(() => { + expect(screen.queryByRole('listbox', { hidden: true })).toBe(null); }); }); @@ -2706,7 +3815,11 @@ describe('', () => { const trigger = screen.getByTestId('trigger'); await user.click(trigger); - const input = await screen.findByTestId('input'); + await waitFor(() => { + expect(trigger).toHaveAttribute('data-popup-open'); + }); + + const input = screen.getByTestId('input'); await user.type(input, 'app'); await user.click(screen.getByRole('option', { name: 'apple' })); @@ -2772,7 +3885,12 @@ describe('', () => { const trigger = screen.getByTestId('trigger'); await user.click(trigger); - const input = await screen.findByTestId('input'); + let popup = await screen.findByTestId('popup'); + await waitFor(() => { + expect(popup).toHaveAttribute('data-open'); + }); + + const input = screen.getByTestId('input'); await user.type(input, 'zz'); await waitFor(() => { @@ -2782,7 +3900,7 @@ describe('', () => { await user.keyboard('{Escape}'); - const popup = screen.getByTestId('popup'); + popup = screen.getByTestId('popup'); await waitFor(() => { expect(popup).toHaveAttribute('data-ending-style'); }); @@ -2796,7 +3914,12 @@ describe('', () => { await user.click(trigger); - const reopenedInput = await screen.findByTestId('input'); + const reopenedPopup = await screen.findByTestId('popup'); + await waitFor(() => { + expect(reopenedPopup).toHaveAttribute('data-open'); + }); + + const reopenedInput = screen.getByTestId('input'); expect(reopenedInput).toHaveValue(''); expect(screen.getByText('apple')).not.toBe(null); }, @@ -2858,7 +3981,12 @@ describe('', () => { const trigger = screen.getByTestId('trigger'); await user.click(trigger); - const input = await screen.findByTestId('input'); + const popup = await screen.findByTestId('popup'); + await waitFor(() => { + expect(popup).toHaveAttribute('data-open'); + }); + + const input = screen.getByTestId('input'); await user.type(input, 'zz'); await waitFor(() => { @@ -2867,14 +3995,14 @@ describe('', () => { await user.keyboard('{Escape}'); - const popup = screen.getByTestId('popup'); await waitFor(() => { expect(popup).toHaveAttribute('data-ending-style'); }); - await user.click(trigger); + fireEvent.mouseDown(trigger); await waitFor(() => { + expect(popup).toHaveAttribute('data-open'); expect(popup).not.toHaveAttribute('data-ending-style'); }); @@ -2884,6 +4012,94 @@ describe('', () => { }, ); + it.skipIf(isJSDOM)( + 'restores the value registry when reopening during close animation', + async ({ onTestFinished }) => { + globalThis.BASE_UI_ANIMATIONS_DISABLED = false; + + onTestFinished(() => { + globalThis.BASE_UI_ANIMATIONS_DISABLED = true; + }); + + const style = ` + @keyframes combobox-close-test { + to { + opacity: 0; + } + } + + .animation-test-popup[data-ending-style] { + animation: combobox-close-test 100ms linear; + } + `; + + const onItemHighlighted = vi.fn(); + const onValueChange = vi.fn(); + const { user } = await render( + + {/* eslint-disable-next-line react/no-danger */} +