@base-ui-components/react
Version:
Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.
1,017 lines (1,004 loc) • 37.5 kB
JavaScript
'use client';
import * as React from 'react';
import { useControlled } from '@base-ui-components/utils/useControlled';
import { useIsoLayoutEffect } from '@base-ui-components/utils/useIsoLayoutEffect';
import { useOnFirstRender } from '@base-ui-components/utils/useOnFirstRender';
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
import { useMergedRefs } from '@base-ui-components/utils/useMergedRefs';
import { visuallyHidden } from '@base-ui-components/utils/visuallyHidden';
import { useRefWithInit } from '@base-ui-components/utils/useRefWithInit';
import { Store, useStore } from '@base-ui-components/utils/store';
import { useValueAsRef } from '@base-ui-components/utils/useValueAsRef';
import { useDismiss, useFloatingRootContext, useInteractions, useListNavigation, useClick } from "../../floating-ui-react/index.js";
import { contains, getTarget } from "../../floating-ui-react/utils.js";
import { createChangeEventDetails, createGenericEventDetails } from "../../utils/createBaseUIEventDetails.js";
import { REASONS } from "../../utils/reasons.js";
import { ComboboxFloatingContext, ComboboxDerivedItemsContext, ComboboxRootContext, ComboboxInputValueContext } from "./ComboboxRootContext.js";
import { selectors } from "../store.js";
import { useOpenChangeComplete } from "../../utils/useOpenChangeComplete.js";
import { useFieldRootContext } from "../../field/root/FieldRootContext.js";
import { useField } from "../../field/useField.js";
import { useFormContext } from "../../form/FormContext.js";
import { useLabelableId } from "../../labelable-provider/useLabelableId.js";
import { createCollatorItemFilter, createSingleSelectionCollatorFilter } from "./utils/index.js";
import { useCoreFilter } from "./utils/useFilter.js";
import { useTransitionStatus } from "../../utils/useTransitionStatus.js";
import { EMPTY_ARRAY, EMPTY_OBJECT } from "../../utils/constants.js";
import { useOpenInteractionType } from "../../utils/useOpenInteractionType.js";
import { useValueChanged } from "../../utils/useValueChanged.js";
import { NOOP } from "../../utils/noop.js";
import { stringifyAsLabel, stringifyAsValue, isGroupedItems } from "../../utils/resolveValueLabel.js";
import { defaultItemEquality, findItemIndex, itemIncludes, removeItem } from "../../utils/itemEquality.js";
import { INITIAL_LAST_HIGHLIGHT, NO_ACTIVE_VALUE } from "./utils/constants.js";
/**
* @internal
*/
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
export function AriaCombobox(props) {
const {
id: idProp,
onOpenChangeComplete: onOpenChangeCompleteProp,
defaultSelectedValue = null,
selectedValue: selectedValueProp,
onSelectedValueChange,
defaultInputValue: defaultInputValueProp,
inputValue: inputValueProp,
selectionMode = 'none',
onItemHighlighted: onItemHighlightedProp,
name: nameProp,
disabled: disabledProp = false,
readOnly = false,
required = false,
inputRef: inputRefProp,
grid = false,
items,
filteredItems: filteredItemsProp,
filter: filterProp,
openOnInputClick = true,
autoHighlight = false,
keepHighlight = false,
highlightItemOnHover = true,
itemToStringLabel,
itemToStringValue,
isItemEqualToValue = defaultItemEquality,
virtualized = false,
inline: inlineProp = false,
fillInputOnItemPress = true,
modal = false,
limit = -1,
autoComplete = 'list',
locale,
submitOnItemClick = false
} = props;
const {
clearErrors
} = useFormContext();
const {
setDirty,
validityData,
shouldValidateOnChange,
setFilled,
name: fieldName,
disabled: fieldDisabled,
validation
} = useFieldRootContext();
const id = useLabelableId({
id: idProp
});
const collatorFilter = useCoreFilter({
locale
});
const [queryChangedAfterOpen, setQueryChangedAfterOpen] = React.useState(false);
const [closeQuery, setCloseQuery] = React.useState(null);
const listRef = React.useRef([]);
const labelsRef = React.useRef([]);
const popupRef = React.useRef(null);
const inputRef = React.useRef(null);
const emptyRef = React.useRef(null);
const keyboardActiveRef = React.useRef(true);
const hadInputClearRef = React.useRef(false);
const chipsContainerRef = React.useRef(null);
const clearRef = React.useRef(null);
const selectionEventRef = React.useRef(null);
const lastHighlightRef = React.useRef(INITIAL_LAST_HIGHLIGHT);
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;
const multiple = selectionMode === 'multiple';
const single = selectionMode === 'single';
const hasInputValue = inputValueProp !== undefined || defaultInputValueProp !== undefined;
const hasItems = items !== undefined;
const hasFilteredItemsProp = filteredItemsProp !== undefined;
let autoHighlightMode;
if (autoHighlight === 'always') {
autoHighlightMode = 'always';
} else {
autoHighlightMode = autoHighlight ? 'input-change' : false;
}
const [selectedValue, setSelectedValueUnwrapped] = useControlled({
controlled: selectedValueProp,
default: multiple ? defaultSelectedValue ?? EMPTY_ARRAY : defaultSelectedValue,
name: 'Combobox',
state: 'selectedValue'
});
const filter = React.useMemo(() => {
if (filterProp === null) {
return () => true;
}
if (filterProp !== undefined) {
return filterProp;
}
if (single && !queryChangedAfterOpen) {
return createSingleSelectionCollatorFilter(collatorFilter, itemToStringLabel, selectedValue);
}
return createCollatorItemFilter(collatorFilter, itemToStringLabel);
}, [filterProp, single, selectedValue, 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.
const initialDefaultInputValue = useRefWithInit(() => {
if (hasInputValue) {
return defaultInputValueProp ?? '';
}
if (single) {
return stringifyAsLabel(selectedValue, itemToStringLabel);
}
return '';
}).current;
const [inputValue, setInputValueUnwrapped] = useControlled({
controlled: inputValueProp,
default: initialDefaultInputValue,
name: 'Combobox',
state: 'inputValue'
});
const [open, setOpenUnwrapped] = useControlled({
controlled: props.open,
default: props.defaultOpen,
name: 'Combobox',
state: 'open'
});
const isGrouped = isGroupedItems(items);
const query = closeQuery ?? (inputValue === '' ? '' : String(inputValue).trim());
const selectedLabelString = single ? stringifyAsLabel(selectedValue, itemToStringLabel) : '';
const shouldBypassFiltering = single && !queryChangedAfterOpen && query !== '' && selectedLabelString !== '' && selectedLabelString.length === query.length && collatorFilter.contains(selectedLabelString, query);
const filterQuery = shouldBypassFiltering ? '' : query;
const shouldIgnoreExternalFiltering = hasItems && hasFilteredItemsProp && shouldBypassFiltering;
const flatItems = React.useMemo(() => {
if (!items) {
return EMPTY_ARRAY;
}
if (isGrouped) {
return items.flatMap(group => group.items);
}
return items;
}, [items, isGrouped]);
const filteredItems = React.useMemo(() => {
if (filteredItemsProp && !shouldIgnoreExternalFiltering) {
return filteredItemsProp;
}
if (!items) {
return EMPTY_ARRAY;
}
if (isGrouped) {
const groupedItems = items;
const resultingGroups = [];
let currentCount = 0;
for (const group of groupedItems) {
if (limit > -1 && currentCount >= limit) {
break;
}
const candidateItems = filterQuery === '' ? group.items : group.items.filter(item => filter(item, filterQuery, itemToStringLabel));
if (candidateItems.length === 0) {
continue;
}
const remainingLimit = limit > -1 ? limit - currentCount : Infinity;
const itemsToTake = candidateItems.slice(0, remainingLimit);
if (itemsToTake.length > 0) {
const newGroup = {
...group,
items: itemsToTake
};
resultingGroups.push(newGroup);
currentCount += itemsToTake.length;
}
}
return resultingGroups;
}
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, <Combobox.Item> 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.
flatItems;
}
const limitedItems = [];
for (const item of flatItems) {
if (limit > -1 && limitedItems.length >= limit) {
break;
}
if (filter(item, filterQuery, itemToStringLabel)) {
limitedItems.push(item);
}
}
return limitedItems;
}, [filteredItemsProp, shouldIgnoreExternalFiltering, items, isGrouped, filterQuery, limit, filter, itemToStringLabel, flatItems]);
const flatFilteredItems = React.useMemo(() => {
if (isGrouped) {
const groups = filteredItems;
return groups.flatMap(g => g.items);
}
return filteredItems;
}, [filteredItems, isGrouped]);
const store = useRefWithInit(() => new Store({
id,
selectedValue,
open,
filter,
query,
items,
selectionMode,
listRef,
labelsRef,
popupRef,
emptyRef,
inputRef,
keyboardActiveRef,
chipsContainerRef,
clearRef,
valuesRef,
allValuesRef,
selectionEventRef,
name,
disabled,
readOnly,
required,
grid,
isGrouped,
virtualized,
openOnInputClick,
itemToStringLabel,
isItemEqualToValue,
modal,
autoHighlight: autoHighlightMode,
submitOnItemClick,
hasInputValue,
mounted: false,
forceMounted: false,
transitionStatus: 'idle',
inline: inlineProp,
activeIndex: null,
selectedIndex: null,
popupProps: {},
inputProps: {},
triggerProps: {},
positionerElement: null,
listElement: null,
triggerElement: null,
inputElement: null,
popupSide: null,
openMethod: null,
inputInsidePopup: true,
onOpenChangeComplete: onOpenChangeCompleteProp || NOOP,
// Placeholder callbacks replaced on first render
setOpen: NOOP,
setInputValue: NOOP,
setSelectedValue: NOOP,
setIndices: NOOP,
onItemHighlighted: NOOP,
handleSelection: NOOP,
getItemProps: () => EMPTY_OBJECT,
forceMount: NOOP,
requestSubmit: NOOP
})).current;
const formValue = selectionMode === 'none' ? inputValue : selectedValue;
const onItemHighlighted = useStableCallback(onItemHighlightedProp);
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);
const inputElement = useStore(store, selectors.inputElement);
const inline = useStore(store, selectors.inline);
const inputInsidePopup = useStore(store, selectors.inputInsidePopup);
const triggerRef = useValueAsRef(triggerElement);
const {
mounted,
setMounted,
transitionStatus
} = useTransitionStatus(open);
const {
openMethod,
triggerProps,
reset: resetOpenInteractionType
} = useOpenInteractionType(open);
useField({
id,
name,
commit: validation.commit,
value: formValue,
controlRef: inputInsidePopup ? triggerRef : inputRef,
getValue: () => formValue
});
const forceMount = useStableCallback(() => {
if (items) {
// Ensure typeahead works on a closed list.
labelsRef.current = flatFilteredItems.map(item => stringifyAsLabel(item, itemToStringLabel));
} else {
store.set('forceMounted', true);
}
});
const initialSelectedValueRef = React.useRef(selectedValue);
useIsoLayoutEffect(() => {
// Ensure the values and labels are registered for programmatic value changes.
if (selectedValue !== initialSelectedValueRef.current) {
forceMount();
}
}, [forceMount, selectedValue]);
const setIndices = useStableCallback(options => {
store.update(options);
const type = options.type || 'none';
if (options.activeIndex === undefined) {
return;
}
if (options.activeIndex === null) {
if (lastHighlightRef.current !== INITIAL_LAST_HIGHLIGHT) {
lastHighlightRef.current = INITIAL_LAST_HIGHLIGHT;
onItemHighlighted(undefined, createGenericEventDetails(type, undefined, {
index: -1
}));
}
} else {
const activeValue = valuesRef.current[options.activeIndex];
lastHighlightRef.current = {
value: activeValue,
index: options.activeIndex
};
onItemHighlighted(activeValue, createGenericEventDetails(type, undefined, {
index: options.activeIndex
}));
}
});
const setInputValue = useStableCallback((next, eventDetails) => {
hadInputClearRef.current = eventDetails.reason === REASONS.inputClear;
props.onInputValueChange?.(next, eventDetails);
if (eventDetails.isCanceled) {
return;
}
// If user is typing, ensure we don't auto-highlight on open due to a race
// with the post-open effect that sets this flag.
if (eventDetails.reason === REASONS.inputChange) {
const hasQuery = next.trim() !== '';
if (hasQuery) {
setQueryChangedAfterOpen(true);
}
// Defer index updates until after the filtered items have been derived to ensure
// `onItemHighlighted` receives the latest item.
pendingQueryHighlightRef.current = {
hasQuery
};
if (hasQuery && autoHighlightMode && store.state.activeIndex == null) {
store.set('activeIndex', 0);
}
}
setInputValueUnwrapped(next);
});
const setOpen = useStableCallback((nextOpen, eventDetails) => {
if (open === nextOpen) {
return;
}
// If the `Empty` component is not used, the positioner or popup should be hidden
// with CSS. In this case, allow the Escape key to bubble to close a parent popup
// if there are no items to show.
if (eventDetails.reason === 'escape-key' && hasItems && flatFilteredItems.length === 0 && !store.state.emptyRef.current) {
eventDetails.allowPropagation();
}
props.onOpenChange?.(nextOpen, eventDetails);
if (eventDetails.isCanceled) {
return;
}
if (!nextOpen && queryChangedAfterOpen) {
if (single) {
setCloseQuery(query);
// Avoid a flicker when closing the popup with an empty query.
if (query === '') {
setQueryChangedAfterOpen(false);
}
} else if (multiple) {
if (inline || inputInsidePopup) {
setIndices({
activeIndex: null
});
} else {
// Freeze the current query so filtering remains stable while exiting.
setCloseQuery(query);
}
// Clear the input immediately on close while retaining filtering via closeQuery for exit animations
// if the input is outside the popup.
setInputValue('', createChangeEventDetails(REASONS.inputClear, eventDetails.event));
}
}
setOpenUnwrapped(nextOpen);
});
const setSelectedValue = useStableCallback((nextValue, eventDetails) => {
// Cast to `any` due to conditional value type (single vs. multiple).
// The runtime implementation already ensures the correct value shape.
onSelectedValueChange?.(nextValue, eventDetails);
if (eventDetails.isCanceled) {
return;
}
setSelectedValueUnwrapped(nextValue);
const shouldFillInput = selectionMode === 'none' && popupRef.current && fillInputOnItemPress || single && !store.state.inputInsidePopup;
if (shouldFillInput) {
setInputValue(stringifyAsLabel(nextValue, itemToStringLabel), createChangeEventDetails(eventDetails.reason, eventDetails.event));
}
if (single && nextValue != null && eventDetails.reason !== REASONS.inputChange && queryChangedAfterOpen) {
setCloseQuery(query);
}
});
const handleSelection = useStableCallback((event, passedValue) => {
let value = passedValue;
if (value === undefined) {
if (activeIndex === null) {
return;
}
value = valuesRef.current[activeIndex];
}
const targetEl = getTarget(event);
const overrideEvent = selectionEventRef.current ?? event;
selectionEventRef.current = null;
const eventDetails = createChangeEventDetails(REASONS.itemPress, overrideEvent);
// Let the link handle the click.
const href = targetEl?.closest('a')?.getAttribute('href');
if (href) {
if (href.startsWith('#')) {
setOpen(false, eventDetails);
}
return;
}
if (multiple) {
const currentSelectedValue = Array.isArray(selectedValue) ? selectedValue : [];
const isCurrentlySelected = itemIncludes(currentSelectedValue, value, store.state.isItemEqualToValue);
const nextValue = isCurrentlySelected ? removeItem(currentSelectedValue, value, store.state.isItemEqualToValue) : [...currentSelectedValue, value];
setSelectedValue(nextValue, eventDetails);
const wasFiltering = inputRef.current ? inputRef.current.value.trim() !== '' : false;
if (!wasFiltering) {
return;
}
if (store.state.inputInsidePopup) {
setInputValue('', createChangeEventDetails(REASONS.inputClear, eventDetails.event));
} else {
setOpen(false, eventDetails);
}
} else {
setSelectedValue(value, eventDetails);
setOpen(false, eventDetails);
}
});
const requestSubmit = useStableCallback(() => {
if (!store.state.submitOnItemClick) {
return;
}
const form = store.state.inputElement?.form;
if (form && typeof form.requestSubmit === 'function') {
form.requestSubmit();
}
});
const handleUnmount = useStableCallback(() => {
setMounted(false);
onOpenChangeComplete?.(false);
setQueryChangedAfterOpen(false);
resetOpenInteractionType();
setCloseQuery(null);
if (selectionMode === 'none') {
setIndices({
activeIndex: null,
selectedIndex: null
});
} else {
setIndices({
activeIndex: null
});
}
// Multiple selection mode:
// If the user typed a filter and didn't select in multiple mode, clear the input
// after close completes to avoid mid-exit flicker and start fresh on next open.
if (multiple && inputRef.current && inputRef.current.value !== '' && !hadInputClearRef.current) {
setInputValue('', createChangeEventDetails(REASONS.inputClear));
}
// Single selection mode:
// - If input is rendered inside the popup, clear it so the next open is blank
// - If input is outside the popup, sync it to the selected value
if (single) {
if (store.state.inputInsidePopup) {
if (inputRef.current && inputRef.current.value !== '') {
setInputValue('', createChangeEventDetails(REASONS.inputClear));
}
} else {
const stringVal = stringifyAsLabel(selectedValue, itemToStringLabel);
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;
setInputValue(stringVal, createChangeEventDetails(reason));
}
}
}
});
// Support composing the Dialog component around an inline combobox.
// `[role="dialog"]` is more interoperable than using a context, e.g. it can work
// with third-party modal libraries, though the limitation is that the closest
// `role=dialog` part must be the animated element.
const resolvedPopupRef = React.useMemo(() => {
if (inline && positionerElement) {
return {
current: positionerElement.closest('[role="dialog"]')
};
}
return popupRef;
}, [inline, positionerElement]);
useOpenChangeComplete({
enabled: !props.actionsRef,
open,
ref: resolvedPopupRef,
onComplete() {
if (!open) {
handleUnmount();
}
}
});
React.useImperativeHandle(props.actionsRef, () => ({
unmount: handleUnmount
}), [handleUnmount]);
useIsoLayoutEffect(function syncSelectedIndex() {
if (open || selectionMode === 'none') {
return;
}
const registry = items ? flatItems : allValuesRef.current;
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]);
useIsoLayoutEffect(() => {
if (items) {
valuesRef.current = flatFilteredItems;
listRef.current.length = flatFilteredItems.length;
}
}, [items, flatFilteredItems]);
useIsoLayoutEffect(() => {
const pendingHighlight = pendingQueryHighlightRef.current;
if (pendingHighlight) {
if (pendingHighlight.hasQuery) {
if (autoHighlightMode) {
store.set('activeIndex', 0);
}
} else if (autoHighlightMode === 'always') {
store.set('activeIndex', 0);
}
pendingQueryHighlightRef.current = null;
}
if (!open && !inline) {
return;
}
const shouldUseFlatFilteredItems = hasItems || hasFilteredItemsProp;
const candidateItems = shouldUseFlatFilteredItems ? flatFilteredItems : valuesRef.current;
const storeActiveIndex = store.state.activeIndex;
if (storeActiveIndex == null) {
if (autoHighlightMode === 'always' && candidateItems.length > 0) {
store.set('activeIndex', 0);
return;
}
if (lastHighlightRef.current !== INITIAL_LAST_HIGHLIGHT) {
lastHighlightRef.current = INITIAL_LAST_HIGHLIGHT;
store.state.onItemHighlighted(undefined, createGenericEventDetails(REASONS.none, undefined, {
index: -1
}));
}
return;
}
if (storeActiveIndex >= candidateItems.length) {
if (lastHighlightRef.current !== INITIAL_LAST_HIGHLIGHT) {
lastHighlightRef.current = INITIAL_LAST_HIGHLIGHT;
store.state.onItemHighlighted(undefined, createGenericEventDetails(REASONS.none, undefined, {
index: -1
}));
}
store.set('activeIndex', null);
return;
}
const nextActiveValue = candidateItems[storeActiveIndex];
const lastHighlightedValue = lastHighlightRef.current.value;
const isSameItem = lastHighlightedValue !== NO_ACTIVE_VALUE && store.state.isItemEqualToValue(nextActiveValue, lastHighlightedValue);
if (lastHighlightRef.current.index !== storeActiveIndex || !isSameItem) {
lastHighlightRef.current = {
value: nextActiveValue,
index: storeActiveIndex
};
store.state.onItemHighlighted(nextActiveValue, createGenericEventDetails(REASONS.none, undefined, {
index: storeActiveIndex
}));
}
}, [activeIndex, autoHighlightMode, hasFilteredItemsProp, hasItems, flatFilteredItems, inline, open, store]);
// When the available items change, ensure the selected value(s) remain valid.
// - Single: if current selection is removed, fall back to defaultSelectedValue if it exists in the list; else null.
// - Multiple: drop any removed selections.
useIsoLayoutEffect(() => {
if (!items || selectionMode === 'none') {
return;
}
const registry = flatItems;
if (multiple) {
const current = Array.isArray(selectedValue) ? selectedValue : EMPTY_ARRAY;
const next = current.filter(v => itemIncludes(registry, v, store.state.isItemEqualToValue));
if (next.length !== current.length) {
setSelectedValueUnwrapped(next);
}
return;
}
const isStillPresent = selectedValue == null ? true : itemIncludes(registry, selectedValue, store.state.isItemEqualToValue);
if (isStillPresent) {
return;
}
let fallback = null;
if (defaultSelectedValue != null && itemIncludes(registry, defaultSelectedValue, store.state.isItemEqualToValue)) {
fallback = defaultSelectedValue;
}
setSelectedValueUnwrapped(fallback);
// Keep the input text in sync when the input is rendered outside the popup.
if (!store.state.inputInsidePopup) {
const stringVal = stringifyAsLabel(fallback, itemToStringLabel);
if (inputRef.current && inputRef.current.value !== stringVal) {
setInputValue(stringVal, createChangeEventDetails(REASONS.none));
}
}
}, [items, flatItems, multiple, selectionMode, selectedValue, defaultSelectedValue, setSelectedValueUnwrapped, itemToStringLabel, store, setInputValue]);
useIsoLayoutEffect(() => {
if (selectionMode === 'none') {
setFilled(String(inputValue) !== '');
return;
}
setFilled(multiple ? Array.isArray(selectedValue) && selectedValue.length > 0 : selectedValue != null);
}, [setFilled, selectionMode, inputValue, selectedValue, multiple]);
// 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) {
setIndices({
activeIndex: null
});
}
}, [hasItems, autoHighlightMode, flatFilteredItems.length, setIndices]);
useValueChanged(query, () => {
if (!open || query === '' || query === String(initialDefaultInputValue)) {
return;
}
setQueryChangedAfterOpen(true);
});
useValueChanged(selectedValue, () => {
if (selectionMode === 'none') {
return;
}
clearErrors(name);
setDirty(selectedValue !== validityData.initialValue);
if (shouldValidateOnChange()) {
validation.commit(selectedValue);
} else {
validation.commit(selectedValue, true);
}
if (multiple && store.state.selectedIndex !== null && (!Array.isArray(selectedValue) || selectedValue.length === 0)) {
setIndices({
activeIndex: null,
selectedIndex: null
});
}
if (single && !hasInputValue && !inputInsidePopup) {
const nextInputValue = stringifyAsLabel(selectedValue, itemToStringLabel);
if (inputValue !== nextInputValue) {
setInputValue(nextInputValue, createChangeEventDetails(REASONS.none));
}
}
});
useValueChanged(inputValue, () => {
if (selectionMode !== 'none') {
return;
}
clearErrors(name);
setDirty(inputValue !== validityData.initialValue);
if (shouldValidateOnChange()) {
validation.commit(inputValue);
} else {
validation.commit(inputValue, true);
}
});
useValueChanged(items, () => {
if (!single || hasInputValue || inputInsidePopup || queryChangedAfterOpen) {
return;
}
const nextInputValue = stringifyAsLabel(selectedValue, itemToStringLabel);
if (inputValue !== nextInputValue) {
setInputValue(nextInputValue, createChangeEventDetails(REASONS.none));
}
});
const floatingRootContext = useFloatingRootContext({
open: inline ? true : open,
onOpenChange: setOpen,
elements: {
reference: inputInsidePopup ? triggerElement : inputElement,
floating: positionerElement
}
});
let ariaHasPopup;
let ariaExpanded;
if (!inline) {
ariaHasPopup = grid ? 'grid' : 'listbox';
ariaExpanded = open ? 'true' : 'false';
}
const role = React.useMemo(() => {
const isPlainInput = inputElement?.tagName === 'INPUT';
const shouldApplyAria = isPlainInput || open;
const reference = isPlainInput ? {
autoComplete: 'off',
spellCheck: 'false',
autoCorrect: 'off',
autoCapitalize: 'none'
} : {};
if (shouldApplyAria) {
reference.role = 'combobox';
reference['aria-expanded'] = ariaExpanded;
reference['aria-haspopup'] = ariaHasPopup;
reference['aria-controls'] = open ? listElement?.id : undefined;
reference['aria-autocomplete'] = autoComplete;
}
return {
reference,
floating: {
role: 'presentation'
}
};
}, [inputElement, open, ariaExpanded, ariaHasPopup, listElement?.id, autoComplete]);
const click = useClick(floatingRootContext, {
enabled: !readOnly && !disabled && openOnInputClick,
event: 'mousedown-only',
toggle: false,
// Apply a small delay for touch to let iOS viewport centering settle.
// This avoids top-bottom flip flickers if the preferred position is "top" when first tapping.
touchOpenDelay: inputInsidePopup ? 0 : 50
});
const dismiss = useDismiss(floatingRootContext, {
enabled: !readOnly && !disabled && !inline,
outsidePressEvent: {
mouse: 'sloppy',
// The visual viewport (affected by the mobile software keyboard) can be
// somewhat small. The user may want to scroll the screen to see more of
// the popup.
touch: 'intentional'
},
// Without a popup, let the Escape key bubble the event up to other popups' handlers.
bubbles: inline ? true : undefined,
outsidePress(event) {
const target = getTarget(event);
return !contains(triggerElement, target) && !contains(clearRef.current, target) && !contains(chipsContainerRef.current, target);
}
});
const listNavigation = useListNavigation(floatingRootContext, {
enabled: !readOnly && !disabled,
id,
listRef,
activeIndex,
selectedIndex,
virtual: true,
loopFocus: true,
allowEscape: !autoHighlightMode,
focusItemOnOpen: queryChangedAfterOpen || selectionMode === 'none' && !autoHighlightMode ? false : 'auto',
focusItemOnHover: highlightItemOnHover,
// `cols` > 1 enables grid navigation.
// Since <Combobox.Row> infers column sizes (and is required when building a grid),
// it works correctly even with a value of `2`.
// Floating UI tests don't require `role="row"` wrappers, so retains the number API.
cols: grid ? 2 : 1,
orientation: grid ? 'horizontal' : undefined,
disabledIndices: EMPTY_ARRAY,
onNavigate(nextActiveIndex, event) {
// Retain the highlight only while actually transitioning out or closed.
if (!event && !open || transitionStatus === 'ending') {
return;
}
if (keepHighlight && nextActiveIndex === null && event && event.type === 'pointerleave') {
return;
}
if (!event) {
setIndices({
activeIndex: nextActiveIndex
});
} else {
setIndices({
activeIndex: nextActiveIndex,
type: keyboardActiveRef.current ? 'keyboard' : 'pointer'
});
}
}
});
const {
getReferenceProps,
getFloatingProps,
getItemProps
} = useInteractions([role, click, dismiss, listNavigation]);
useOnFirstRender(() => {
store.update({
inline: inlineProp,
popupProps: getFloatingProps(),
inputProps: getReferenceProps(),
triggerProps,
getItemProps,
setOpen,
setInputValue,
setSelectedValue,
setIndices,
onItemHighlighted,
handleSelection,
forceMount,
requestSubmit
});
});
useIsoLayoutEffect(() => {
store.update({
id,
selectedValue,
open,
mounted,
transitionStatus,
items,
inline: inlineProp,
popupProps: getFloatingProps(),
inputProps: getReferenceProps(),
triggerProps,
openMethod,
getItemProps,
selectionMode,
name,
disabled,
readOnly,
required,
grid,
isGrouped,
virtualized,
onOpenChangeComplete,
openOnInputClick,
itemToStringLabel,
modal,
autoHighlight: autoHighlightMode,
isItemEqualToValue,
submitOnItemClick,
hasInputValue,
requestSubmit
});
}, [store, id, selectedValue, open, mounted, transitionStatus, items, getFloatingProps, getReferenceProps, getItemProps, openMethod, triggerProps, selectionMode, name, disabled, readOnly, required, validation, grid, isGrouped, virtualized, onOpenChangeComplete, openOnInputClick, itemToStringLabel, modal, isItemEqualToValue, submitOnItemClick, hasInputValue, inlineProp, requestSubmit, autoHighlightMode]);
const hiddenInputRef = useMergedRefs(inputRefProp, validation.inputRef);
const itemsContextValue = React.useMemo(() => ({
query,
filteredItems,
flatFilteredItems
}), [query, filteredItems, flatFilteredItems]);
const serializedValue = React.useMemo(() => {
if (Array.isArray(formValue)) {
return '';
}
return stringifyAsValue(formValue, itemToStringValue);
}, [formValue, itemToStringValue]);
const hasMultipleSelection = multiple && Array.isArray(selectedValue) && selectedValue.length > 0;
const hiddenInputs = React.useMemo(() => {
if (!multiple || !Array.isArray(selectedValue) || !name) {
return null;
}
return selectedValue.map(value => {
const currentSerializedValue = stringifyAsValue(value, itemToStringValue);
return /*#__PURE__*/_jsx("input", {
type: "hidden",
name: name,
value: currentSerializedValue
}, currentSerializedValue);
});
}, [multiple, selectedValue, name, itemToStringValue]);
const children = /*#__PURE__*/_jsxs(React.Fragment, {
children: [props.children, /*#__PURE__*/_jsx("input", {
...validation.getInputValidationProps({
// Move focus when the hidden input is focused.
onFocus() {
if (inputInsidePopup) {
triggerElement?.focus();
return;
}
(inputRef.current || triggerElement)?.focus();
},
// Handle browser autofill.
onChange(event) {
// Workaround for https://github.com/facebook/react/issues/9023
if (event.nativeEvent.defaultPrevented) {
return;
}
const nextValue = event.target.value;
const details = createChangeEventDetails(REASONS.inputChange, event.nativeEvent);
function handleChange() {
// Browser autofill only writes a single scalar value.
if (multiple) {
return;
}
if (selectionMode === 'none') {
setDirty(nextValue !== validityData.initialValue);
setInputValue(nextValue, details);
if (shouldValidateOnChange()) {
validation.commit(nextValue);
}
return;
}
const matchingValue = valuesRef.current.find(v => {
const candidate = stringifyAsValue(v, itemToStringValue);
if (candidate.toLowerCase() === nextValue.toLowerCase()) {
return true;
}
return false;
});
if (matchingValue != null) {
setDirty(matchingValue !== validityData.initialValue);
setSelectedValue?.(matchingValue, details);
if (shouldValidateOnChange()) {
validation.commit(matchingValue);
}
}
}
if (items) {
handleChange();
} else {
forceMount();
queueMicrotask(handleChange);
}
}
}),
id: id,
name: multiple || selectionMode === 'none' ? undefined : name,
disabled: disabled,
required: required && !hasMultipleSelection,
readOnly: readOnly,
value: serializedValue,
ref: hiddenInputRef,
style: visuallyHidden,
tabIndex: -1,
"aria-hidden": true
}), hiddenInputs]
});
return /*#__PURE__*/_jsx(ComboboxRootContext.Provider, {
value: store,
children: /*#__PURE__*/_jsx(ComboboxFloatingContext.Provider, {
value: floatingRootContext,
children: /*#__PURE__*/_jsx(ComboboxDerivedItemsContext.Provider, {
value: itemsContextValue,
children: /*#__PURE__*/_jsx(ComboboxInputValueContext.Provider, {
value: inputValue,
children: children
})
})
})
});
}