@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.
356 lines (350 loc) • 14.6 kB
JavaScript
'use client';
import * as React from 'react';
import { useStore } from '@base-ui-components/utils/store';
import { useStableCallback } from '@base-ui-components/utils/useStableCallback';
import { isAndroid, isFirefox } from '@base-ui-components/utils/detectBrowser';
import { useBaseUiId } from "../../utils/useBaseUiId.js";
import { useRenderElement } from "../../utils/useRenderElement.js";
import { useComboboxDerivedItemsContext, useComboboxInputValueContext, useComboboxRootContext } from "../root/ComboboxRootContext.js";
import { selectors } from "../store.js";
import { pressableTriggerOpenStateMapping } from "../../utils/popupStateMapping.js";
import { useFieldRootContext } from "../../field/root/FieldRootContext.js";
import { fieldValidityMapping } from "../../field/utils/constants.js";
import { useLabelableContext } from "../../labelable-provider/LabelableContext.js";
import { useComboboxChipsContext } from "../chips/ComboboxChipsContext.js";
import { stopEvent } from "../../floating-ui-react/utils.js";
import { useComboboxPositionerContext } from "../positioner/ComboboxPositionerContext.js";
import { createChangeEventDetails } from "../../utils/createBaseUIEventDetails.js";
import { REASONS } from "../../utils/reasons.js";
import { useDirection } from "../../direction-provider/DirectionContext.js";
const stateAttributesMapping = {
...pressableTriggerOpenStateMapping,
...fieldValidityMapping,
popupSide: side => side ? {
'data-popup-side': side
} : null,
listEmpty: empty => empty ? {
'data-list-empty': ''
} : null
};
/**
* A text input to search for items in the list.
* Renders an `<input>` element.
*/
export const ComboboxInput = /*#__PURE__*/React.forwardRef(function ComboboxInput(componentProps, forwardedRef) {
const {
render,
className,
disabled: disabledProp = false,
id: idProp,
...elementProps
} = componentProps;
const {
state: fieldState,
disabled: fieldDisabled,
setTouched,
setFocused,
validationMode,
validation
} = useFieldRootContext();
const {
labelId
} = useLabelableContext();
const comboboxChipsContext = useComboboxChipsContext();
const positioning = useComboboxPositionerContext(true);
const hasPositionerParent = Boolean(positioning);
const store = useComboboxRootContext();
const {
filteredItems
} = useComboboxDerivedItemsContext();
// `inputValue` can't be placed in the store.
// https://github.com/mui/base-ui/issues/2703
const inputValue = useComboboxInputValueContext();
const id = useBaseUiId(idProp);
const direction = useDirection();
const comboboxDisabled = useStore(store, selectors.disabled);
const readOnly = useStore(store, selectors.readOnly);
const name = useStore(store, selectors.name);
const selectionMode = useStore(store, selectors.selectionMode);
const autoHighlightMode = useStore(store, selectors.autoHighlight);
const inputProps = useStore(store, selectors.inputProps);
const triggerProps = useStore(store, selectors.triggerProps);
const open = useStore(store, selectors.open);
const mounted = useStore(store, selectors.mounted);
const selectedValue = useStore(store, selectors.selectedValue);
const popupSideValue = useStore(store, selectors.popupSide);
const positionerElement = useStore(store, selectors.positionerElement);
const autoHighlightEnabled = Boolean(autoHighlightMode);
const popupSide = mounted && positionerElement ? popupSideValue : null;
const disabled = fieldDisabled || comboboxDisabled || disabledProp;
const listEmpty = filteredItems.length === 0;
const [composingValue, setComposingValue] = React.useState(null);
const isComposingRef = React.useRef(false);
const setInputElement = useStableCallback(element => {
const isInsidePopup = hasPositionerParent || store.state.inline;
if (isInsidePopup && !store.state.hasInputValue) {
store.state.setInputValue('', createChangeEventDetails(REASONS.none));
}
store.update({
inputElement: element,
inputInsidePopup: isInsidePopup
});
});
const state = React.useMemo(() => ({
...fieldState,
open,
disabled,
readOnly,
popupSide,
listEmpty
}), [fieldState, open, disabled, readOnly, popupSide, listEmpty]);
function handleKeyDown(event) {
if (!comboboxChipsContext) {
return undefined;
}
let nextIndex;
const {
highlightedChipIndex
} = comboboxChipsContext;
if (highlightedChipIndex !== undefined) {
if (event.key === 'ArrowLeft') {
event.preventDefault();
if (highlightedChipIndex > 0) {
nextIndex = highlightedChipIndex - 1;
} else {
nextIndex = undefined;
}
} else if (event.key === 'ArrowRight') {
event.preventDefault();
if (highlightedChipIndex < selectedValue.length - 1) {
nextIndex = highlightedChipIndex + 1;
} else {
nextIndex = undefined;
}
} else if (event.key === 'Backspace' || event.key === 'Delete') {
event.preventDefault();
// Move highlight appropriately after removal.
const computedNextIndex = highlightedChipIndex >= selectedValue.length - 1 ? selectedValue.length - 2 : 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'
});
}
return nextIndex;
}
// Handle navigation when no chip is highlighted
if (event.key === 'ArrowLeft' && (event.currentTarget.selectionStart ?? 0) === 0 && selectedValue.length > 0) {
event.preventDefault();
const lastChipIndex = Math.max(selectedValue.length - 1, 0);
nextIndex = lastChipIndex;
} else if (event.key === 'Backspace' && event.currentTarget.value === '' && selectedValue.length > 0) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
type: 'keyboard'
});
event.preventDefault();
}
return nextIndex;
}
const element = useRenderElement('input', componentProps, {
state,
ref: [forwardedRef, store.state.inputRef, setInputElement],
props: [inputProps, triggerProps, {
type: 'text',
value: componentProps.value ?? composingValue ?? inputValue,
'aria-readonly': readOnly || undefined,
'aria-labelledby': labelId,
disabled,
readOnly,
...(selectionMode === 'none' && name && {
name
}),
id,
onFocus() {
setFocused(true);
},
onBlur() {
setTouched(true);
setFocused(false);
if (validationMode === 'onBlur') {
const valueToValidate = selectionMode === 'none' ? inputValue : selectedValue;
validation.commit(valueToValidate);
}
},
onCompositionStart(event) {
if (isAndroid) {
return;
}
isComposingRef.current = true;
setComposingValue(event.currentTarget.value);
},
onCompositionEnd(event) {
isComposingRef.current = false;
const next = event.currentTarget.value;
setComposingValue(null);
store.state.setInputValue(next, createChangeEventDetails(REASONS.inputChange, event.nativeEvent));
},
onChange(event) {
// During IME composition, avoid propagating controlled updates to prevent
// filtering the options prematurely so `Empty` won't show incorrectly.
// We can't rely on this check for Android due to how it handles composition
// events with some keyboards (e.g. Samsung keyboard with predictive text on
// treats all text as always-composing).
// https://github.com/mui/base-ui/issues/2942
if (isComposingRef.current) {
const nextVal = event.currentTarget.value;
setComposingValue(nextVal);
if (nextVal === '' && !store.state.openOnInputClick && !store.state.inputInsidePopup) {
store.state.setOpen(false, createChangeEventDetails(REASONS.inputClear, event.nativeEvent));
}
const trimmed = nextVal.trim();
const shouldMaintainHighlight = autoHighlightEnabled && trimmed !== '';
if (!readOnly && !disabled) {
if (trimmed !== '') {
store.state.setOpen(true, createChangeEventDetails(REASONS.inputChange, event.nativeEvent));
if (!autoHighlightEnabled) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer'
});
}
}
}
if (open && store.state.activeIndex !== null && !shouldMaintainHighlight) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer'
});
}
return;
}
store.state.setInputValue(event.currentTarget.value, createChangeEventDetails(REASONS.inputChange, event.nativeEvent));
const empty = event.currentTarget.value === '';
const clearDetails = createChangeEventDetails(REASONS.inputClear, event.nativeEvent);
if (empty && !store.state.inputInsidePopup) {
if (selectionMode === 'single') {
store.state.setSelectedValue(null, clearDetails);
}
if (!store.state.openOnInputClick) {
store.state.setOpen(false, clearDetails);
}
}
const trimmed = event.currentTarget.value.trim();
if (!readOnly && !disabled) {
if (trimmed !== '') {
store.state.setOpen(true, createChangeEventDetails(REASONS.inputChange, event.nativeEvent));
// When autoHighlight is enabled, keep the highlight (will be set to 0 in root).
if (!autoHighlightEnabled) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer'
});
}
}
}
// When the user types, ensure the list resets its highlight so that
// virtual focus returns to the input (aria-activedescendant is
// cleared).
if (open && store.state.activeIndex !== null && !autoHighlightEnabled) {
store.state.setIndices({
activeIndex: null,
selectedIndex: null,
type: store.state.keyboardActiveRef.current ? 'keyboard' : 'pointer'
});
}
},
onKeyDown(event) {
if (disabled || readOnly) {
return;
}
if (event.ctrlKey || event.shiftKey || event.altKey || event.metaKey) {
return;
}
store.state.keyboardActiveRef.current = true;
const input = event.currentTarget;
const scrollAmount = input.scrollWidth - input.clientWidth;
const isRTL = direction === 'rtl';
if (event.key === 'Home') {
stopEvent(event);
const cursor = isFirefox && isRTL ? input.value.length : 0;
input.setSelectionRange(cursor, cursor);
input.scrollLeft = 0;
return;
}
if (event.key === 'End') {
stopEvent(event);
const cursor = isFirefox && isRTL ? 0 : input.value.length;
input.setSelectionRange(cursor, cursor);
input.scrollLeft = isRTL ? -scrollAmount : scrollAmount;
return;
}
if (!mounted && event.key === 'Escape') {
const isClear = selectionMode === 'multiple' && Array.isArray(selectedValue) ? selectedValue.length === 0 : selectedValue === null;
const details = createChangeEventDetails(REASONS.escapeKey, event.nativeEvent);
const value = selectionMode === 'multiple' ? [] : null;
store.state.setInputValue('', details);
store.state.setSelectedValue(value, details);
if (!isClear && !store.state.inline && !details.isPropagationAllowed) {
event.stopPropagation();
}
return;
}
// Handle deletion when no chip is highlighted and the input is empty.
if (comboboxChipsContext && event.key === 'Backspace' && input.value === '' && comboboxChipsContext.highlightedChipIndex === undefined && Array.isArray(selectedValue) && selectedValue.length > 0) {
const newValue = selectedValue.slice(0, -1);
// 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(newValue, createChangeEventDetails(REASONS.none, event.nativeEvent));
return;
}
const nextIndex = handleKeyDown(event);
comboboxChipsContext?.setHighlightedChipIndex(nextIndex);
if (nextIndex !== undefined) {
comboboxChipsContext?.chipsRef.current[nextIndex]?.focus();
} else {
store.state.inputRef.current?.focus();
}
// event.isComposing
if (event.which === 229) {
return;
}
if (event.key === 'Enter' && open) {
const activeIndex = store.state.activeIndex;
const nativeEvent = event.nativeEvent;
if (activeIndex === null) {
// Allow form submission when no item is highlighted.
store.state.setOpen(false, createChangeEventDetails(REASONS.none, nativeEvent));
return;
}
stopEvent(event);
const listItem = store.state.listRef.current[activeIndex];
if (listItem) {
store.state.selectionEventRef.current = nativeEvent;
listItem.click();
store.state.selectionEventRef.current = null;
}
}
},
onPointerMove() {
store.state.keyboardActiveRef.current = false;
},
onPointerDown() {
store.state.keyboardActiveRef.current = false;
}
}, validation ? validation.getValidationProps(elementProps) : elementProps],
stateAttributesMapping
});
return element;
});
if (process.env.NODE_ENV !== "production") ComboboxInput.displayName = "ComboboxInput";