UNPKG

@carbon/react

Version:

React components for the Carbon Design System

635 lines (633 loc) 24.9 kB
/** * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ import { usePrefix } from "../../internal/usePrefix.js"; import { Text } from "../Text/Text.js"; import { End, Enter, Escape, Home, Space } from "../../internal/keyboard/keys.js"; import { match } from "../../internal/keyboard/match.js"; import { useId } from "../../internal/useId.js"; import { deprecate } from "../../prop-types/deprecate.js"; import { defaultItemToString } from "../../internal/defaultItemToString.js"; import { isComponentElement } from "../../internal/utils.js"; import { useFeatureFlag } from "../FeatureFlags/index.js"; import { useNormalizedInputProps } from "../../internal/useNormalizedInputProps.js"; import { AILabel } from "../AILabel/index.js"; import { ListBoxSizePropType } from "../ListBox/ListBoxPropTypes.js"; import { FormContext } from "../FluidForm/FormContext.js"; import ListBox from "../ListBox/index.js"; import ListBoxSelection from "../ListBox/next/ListBoxSelection.js"; import ListBoxTrigger from "../ListBox/next/ListBoxTrigger.js"; import { mergeRefs } from "../../tools/mergeRefs.js"; import classNames from "classnames"; import { cloneElement, forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import PropTypes from "prop-types"; import { jsx, jsxs } from "react/jsx-runtime"; import { Checkmark, WarningAltFilled, WarningFilled } from "@carbon/icons-react"; import { autoUpdate, flip, hide, useFloating } from "@floating-ui/react"; import { useCombobox } from "downshift"; import isEqual from "react-fast-compare"; //#region src/components/ComboBox/ComboBox.tsx /** * Copyright IBM Corp. 2016, 2026 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ const { InputBlur, InputKeyDownEnter, FunctionToggleMenu, ToggleButtonClick, ItemMouseMove, InputKeyDownArrowUp, InputKeyDownArrowDown, MenuMouseLeave, ItemClick, FunctionSelectItem } = useCombobox.stateChangeTypes; const defaultShouldFilterItem = () => true; const isDisabledItem = (item) => item !== null && typeof item === "object" && "disabled" in item && Boolean(item.disabled); const autocompleteCustomFilter = ({ item, inputValue }) => { if (inputValue === null || inputValue === "") return true; const lowercaseItem = item.toLowerCase(); const lowercaseInput = inputValue.toLowerCase(); return lowercaseItem.startsWith(lowercaseInput); }; const getInputValue = ({ initialSelectedItem, itemToString, selectedItem, prevSelectedItem }) => { if (selectedItem !== null && typeof selectedItem !== "undefined") return itemToString(selectedItem); if (typeof prevSelectedItem === "undefined" && initialSelectedItem !== null && typeof initialSelectedItem !== "undefined") return itemToString(initialSelectedItem); return ""; }; const findHighlightedIndex = ({ items, itemToString = defaultItemToString }, inputValue) => { if (!inputValue) return -1; const searchValue = inputValue.toLowerCase(); for (let i = 0; i < items.length; i++) { const item = itemToString(items[i]).toLowerCase(); if (!isDisabledItem(items[i]) && item.indexOf(searchValue) !== -1) return i; } return -1; }; const ComboBox = forwardRef((props, ref) => { const prevInputLengthRef = useRef(0); const inputRef = useRef(null); const { ["aria-label"]: ariaLabel = "Choose an item", ariaLabel: deprecatedAriaLabel, autoAlign = false, className: containerClassName, decorator, direction = "bottom", disabled = false, downshiftActions, downshiftProps, helperText, id, initialSelectedItem, invalid, invalidText, items, itemToElement = null, itemToString = defaultItemToString, light, onChange, onInputChange, onToggleClick, placeholder, readOnly, selectedItem: selectedItemProp, shouldFilterItem = defaultShouldFilterItem, size, titleText, translateWithId, typeahead = false, warn, warnText, allowCustomValue = false, slug, inputProps, ...rest } = props; const enableFloatingStyles = useFeatureFlag("enable-v12-dynamic-floating-styles") || autoAlign; const { refs, floatingStyles, middlewareData } = useFloating(enableFloatingStyles ? { placement: direction, strategy: "fixed", middleware: autoAlign ? [flip(), hide()] : void 0, whileElementsMounted: autoUpdate } : {}); const referenceElement = refs?.reference?.current; const parentWidth = typeof HTMLElement !== "undefined" && referenceElement instanceof HTMLElement ? referenceElement.clientWidth : void 0; useEffect(() => { if (enableFloatingStyles) { const updatedFloatingStyles = { ...floatingStyles, visibility: middlewareData.hide?.referenceHidden ? "hidden" : "visible" }; Object.keys(updatedFloatingStyles).forEach((style) => { if (refs.floating.current) refs.floating.current.style[style] = updatedFloatingStyles[style]; }); if (parentWidth && refs.floating.current) refs.floating.current.style.width = parentWidth + "px"; } }, [ enableFloatingStyles, floatingStyles, refs.floating, parentWidth ]); const [inputValue, setInputValue] = useState(getInputValue({ initialSelectedItem, itemToString, selectedItem: selectedItemProp })); const [typeaheadText, setTypeaheadText] = useState(""); useEffect(() => { if (typeahead) { if (inputValue.length >= prevInputLengthRef.current) if (inputValue) { const filteredItems = items.filter((item) => !isDisabledItem(item) && autocompleteCustomFilter({ item: itemToString(item), inputValue })); if (filteredItems.length > 0) setTypeaheadText(itemToString(filteredItems[0]).slice(inputValue.length)); else setTypeaheadText(""); } else setTypeaheadText(""); else setTypeaheadText(""); prevInputLengthRef.current = inputValue.length; } }, [ typeahead, inputValue, items, itemToString, autocompleteCustomFilter ]); const isManualClearingRef = useRef(false); const committedCustomValueRef = useRef(""); const [isClearing, setIsClearing] = useState(false); const prefix = usePrefix(); const { isFluid } = useContext(FormContext); const textInput = useRef(null); const comboBoxInstanceId = useId(); const [isFocused, setIsFocused] = useState(false); const prevInputValue = useRef(inputValue); const prevSelectedItemProp = useRef(selectedItemProp); useEffect(() => { isManualClearingRef.current = isClearing; if (isClearing) setIsClearing(false); }, [isClearing]); useEffect(() => { if (prevSelectedItemProp.current !== selectedItemProp) { const currentInputValue = getInputValue({ initialSelectedItem, itemToString, selectedItem: selectedItemProp, prevSelectedItem: prevSelectedItemProp.current }); if (inputValue !== currentInputValue) { setInputValue(currentInputValue); onChange({ selectedItem: selectedItemProp, inputValue: currentInputValue }); } prevSelectedItemProp.current = selectedItemProp; } }, [selectedItemProp]); const filterItems = (items, itemToString, inputValue) => items.filter((item) => typeahead ? autocompleteCustomFilter({ item: itemToString(item), inputValue }) : shouldFilterItem ? shouldFilterItem({ item, itemToString, inputValue }) : defaultShouldFilterItem()); useEffect(() => { if (prevInputValue.current !== inputValue) { prevInputValue.current = inputValue; onInputChange?.(inputValue); } }, [inputValue]); const handleSelectionClear = () => { if (textInput?.current) textInput.current.focus(); }; const filteredItems = (inputValue) => filterItems(items, itemToString, inputValue || null); const indexToHighlight = (inputValue) => findHighlightedIndex({ ...props, items: filteredItems(inputValue) }, inputValue); const stateReducer = useCallback((state, actionAndChanges) => { const { type, changes } = actionAndChanges; const { highlightedIndex } = changes; switch (type) { case InputBlur: if (allowCustomValue && highlightedIndex === -1) { const inputValue = state.inputValue ?? ""; const currentSelectedItem = typeof changes.selectedItem === "undefined" ? state.selectedItem : changes.selectedItem; if (currentSelectedItem !== null && typeof currentSelectedItem !== "undefined" && itemToString(currentSelectedItem) === inputValue && items.some((item) => isEqual(item, currentSelectedItem))) return changes; const nextSelectedItem = inputValue === "" ? null : items.find((item) => itemToString(item) === inputValue) ?? inputValue; const isCustomSelection = typeof nextSelectedItem === "string" && nextSelectedItem !== "" && !items.some((item) => isEqual(item, nextSelectedItem)); if (!isEqual(currentSelectedItem, nextSelectedItem) && onChange) { onChange({ selectedItem: nextSelectedItem, inputValue }); committedCustomValueRef.current = isCustomSelection ? inputValue : ""; } return { ...changes, selectedItem: nextSelectedItem }; } if (state.inputValue && highlightedIndex === -1 && changes.selectedItem) return { ...changes, inputValue: itemToString(changes.selectedItem) }; if (!allowCustomValue) { const currentInput = state.inputValue ?? ""; if (!(!!currentInput && items.some((item) => itemToString(item) === currentInput))) { const selectedItem = typeof selectedItemProp !== "undefined" ? selectedItemProp : state.selectedItem; const restoredInput = selectedItem !== null ? itemToString(selectedItem) : ""; return { ...changes, inputValue: restoredInput }; } } return changes; case InputKeyDownEnter: if (!allowCustomValue) if (state.highlightedIndex !== -1) { const highlightedItem = filterItems(items, itemToString, inputValue)[state.highlightedIndex]; if (highlightedItem && !isDisabledItem(highlightedItem)) return { ...changes, selectedItem: highlightedItem, inputValue: itemToString(highlightedItem) }; } else { const autoIndex = indexToHighlight(inputValue); if (autoIndex !== -1) { const matchingItem = items[autoIndex]; if (matchingItem && !isDisabledItem(matchingItem)) return { ...changes, selectedItem: matchingItem, inputValue: itemToString(matchingItem) }; } if (state.selectedItem !== null) return { ...changes, selectedItem: null, inputValue }; } return { ...changes, isOpen: true }; case FunctionToggleMenu: case ToggleButtonClick: if (state.isOpen && !changes.isOpen && !allowCustomValue) { const currentInput = state.inputValue ?? ""; if (!(!!currentInput && items.some((item) => itemToString(item) === currentInput))) { const selectedItem = typeof selectedItemProp !== "undefined" ? selectedItemProp : state.selectedItem; const restoredInput = selectedItem !== null ? itemToString(selectedItem) : ""; return { ...changes, inputValue: restoredInput }; } } return changes; case MenuMouseLeave: return { ...changes, highlightedIndex: state.highlightedIndex }; case InputKeyDownArrowUp: case InputKeyDownArrowDown: if (highlightedIndex === -1) return { ...changes, highlightedIndex: 0 }; return changes; case ItemMouseMove: return { ...changes, highlightedIndex: state.highlightedIndex }; default: return changes; } }, [ allowCustomValue, inputValue, itemToString, items, onChange ]); const handleToggleClick = (isOpen) => (event) => { if (onToggleClick) onToggleClick(event); if (readOnly) { event.preventDownshiftDefault = true; event?.persist?.(); return; } if (event.target === textInput.current && isOpen) { event.preventDownshiftDefault = true; event?.persist?.(); } }; const normalizedProps = useNormalizedInputProps({ id, readOnly, disabled: disabled || false, invalid: invalid || false, invalidText, warn: warn || false, warnText }); const className = classNames(`${prefix}--combo-box`, { [`${prefix}--combo-box--invalid--focused`]: invalid && isFocused, [`${prefix}--list-box--up`]: direction === "top", [`${prefix}--combo-box--warning`]: normalizedProps.warn, [`${prefix}--combo-box--readonly`]: readOnly, [`${prefix}--autoalign`]: enableFloatingStyles }); const titleClasses = classNames(`${prefix}--label`, { [`${prefix}--label--disabled`]: disabled }); const helperTextId = `combobox-helper-text-${comboBoxInstanceId}`; const warnTextId = `combobox-warn-text-${comboBoxInstanceId}`; const invalidTextId = `combobox-invalid-text-${comboBoxInstanceId}`; const helperClasses = classNames(`${prefix}--form__helper-text`, { [`${prefix}--form__helper-text--disabled`]: disabled }); const wrapperClasses = classNames(`${prefix}--list-box__wrapper`, [containerClassName, { [`${prefix}--list-box__wrapper--fluid--invalid`]: isFluid && invalid, [`${prefix}--list-box__wrapper--slug`]: slug, [`${prefix}--list-box__wrapper--decorator`]: decorator }]); const inputClasses = classNames(`${prefix}--text-input`, { [`${prefix}--text-input--empty`]: !inputValue, [`${prefix}--combo-box--input--focus`]: isFocused }); const candidate = slug ?? decorator; const candidateIsAILabel = isComponentElement(candidate, AILabel); const normalizedDecorator = candidateIsAILabel ? cloneElement(candidate, { size: "mini" }) : candidate; const { getInputProps, getItemProps, getLabelProps, getMenuProps, getToggleButtonProps, isOpen, highlightedIndex, selectedItem, closeMenu, openMenu, reset, selectItem, setHighlightedIndex, setInputValue: downshiftSetInputValue, toggleMenu } = useCombobox({ items: filterItems(items, itemToString, inputValue), inputValue, itemToString: (item) => { return itemToString(item); }, onInputValueChange({ inputValue }) { const normalizedInput = inputValue || ""; setInputValue(normalizedInput); setHighlightedIndex(indexToHighlight(normalizedInput)); }, onHighlightedIndexChange: ({ highlightedIndex }) => { if (highlightedIndex > -1) { const highlightedItem = document.querySelectorAll(`li.${prefix}--list-box__menu-item[role="option"]`)[highlightedIndex]; if (highlightedItem) highlightedItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); } }, initialSelectedItem, inputId: id, stateReducer, isItemDisabled: isDisabledItem, ...downshiftProps, onStateChange: ({ type, selectedItem: newSelectedItem }) => { downshiftProps?.onStateChange?.({ type, selectedItem: newSelectedItem }); if (isManualClearingRef.current) return; if ((type === ItemClick || type === FunctionSelectItem || type === InputKeyDownEnter) && typeof newSelectedItem !== "undefined" && !isEqual(selectedItemProp, newSelectedItem)) { if (items.some((item) => isEqual(item, newSelectedItem))) committedCustomValueRef.current = ""; onChange({ selectedItem: newSelectedItem }); } } }); const currentSelectedItem = typeof selectedItemProp !== "undefined" ? selectedItemProp : selectedItem; useEffect(() => { if (downshiftActions) downshiftActions.current = { closeMenu, openMenu, reset, selectItem, setHighlightedIndex, setInputValue: downshiftSetInputValue, toggleMenu }; }, [ closeMenu, openMenu, reset, selectItem, setHighlightedIndex, downshiftSetInputValue, toggleMenu ]); const buttonProps = getToggleButtonProps({ disabled: disabled || readOnly, onClick: handleToggleClick(isOpen), onMouseUp(event) { if (isOpen) event.stopPropagation(); } }); const handleFocus = (evt) => { setIsFocused(evt.type === "focus"); if (!inputRef.current?.value && evt.type === "blur") selectItem(null); }; const readOnlyEventHandlers = readOnly ? { onKeyDown: (evt) => { if (evt.key !== "Tab") evt.preventDefault(); }, onClick: (evt) => { evt.preventDefault(); evt.currentTarget.focus(); } } : {}; const ariaDescribedBy = normalizedProps.invalid && invalidText && invalidTextId || normalizedProps.warn && warnText && warnTextId || helperText && !isFluid && helperTextId || void 0; const menuProps = useMemo(() => getMenuProps({ ref: enableFloatingStyles ? refs.setFloating : null }), [ enableFloatingStyles, deprecatedAriaLabel, ariaLabel, getMenuProps, refs.setFloating ]); useEffect(() => { if (textInput.current) { if (inputRef.current && typeaheadText) { const selectionStart = inputValue.length; const selectionEnd = selectionStart + typeaheadText.length; inputRef.current.value = inputValue + typeaheadText; inputRef.current.setSelectionRange(selectionStart, selectionEnd); } } }, [inputValue, typeaheadText]); return /* @__PURE__ */ jsxs("div", { className: wrapperClasses, children: [ titleText && /* @__PURE__ */ jsx(Text, { as: "label", className: titleClasses, ...getLabelProps(), children: titleText }), /* @__PURE__ */ jsxs(ListBox, { onFocus: handleFocus, onBlur: handleFocus, className, disabled, invalid: normalizedProps.invalid, invalidText, invalidTextId, isOpen, light, size, warn: normalizedProps.warn, ref: enableFloatingStyles ? refs.setReference : null, warnText, warnTextId, children: [ /* @__PURE__ */ jsxs("div", { className: `${prefix}--list-box__field`, children: [ /* @__PURE__ */ jsx("input", { disabled, className: inputClasses, type: "text", tabIndex: 0, "aria-haspopup": "listbox", title: textInput?.current?.value, ...getInputProps({ "aria-label": titleText ? void 0 : deprecatedAriaLabel || ariaLabel, "aria-controls": menuProps.id, placeholder, value: inputValue, ...inputProps, onChange: (e) => { const newValue = e.target.value; const shouldClearSelection = allowCustomValue && committedCustomValueRef.current && inputValue === committedCustomValueRef.current && newValue === ""; setInputValue(newValue); downshiftSetInputValue(newValue); if (shouldClearSelection) { setIsClearing(true); onChange({ selectedItem: null, inputValue: "" }); selectItem(null); committedCustomValueRef.current = ""; } }, ref: mergeRefs(textInput, ref, inputRef), onKeyDown: (event) => { if (match(event, Space)) event.stopPropagation(); if (match(event, Enter) && (!inputValue || allowCustomValue)) { toggleMenu(); if (highlightedIndex !== -1) selectItem(filterItems(items, itemToString, inputValue)[highlightedIndex]); if (allowCustomValue && isOpen && inputValue && highlightedIndex === -1) { committedCustomValueRef.current = inputValue; onChange({ selectedItem: null, inputValue }); } event.preventDownshiftDefault = true; event?.persist?.(); } if (match(event, Escape) && inputValue) { if (event.target === textInput.current && isOpen) { toggleMenu(); event.preventDownshiftDefault = true; event?.persist?.(); } } if (match(event, Home) && event.code !== "Numpad7") event.target.setSelectionRange(0, 0); if (match(event, End) && event.code !== "Numpad1") event.target.setSelectionRange(event.target.value.length, event.target.value.length); if (event.altKey && event.key == "ArrowDown") { event.preventDownshiftDefault = true; if (!isOpen) toggleMenu(); } if (event.altKey && event.key == "ArrowUp") { event.preventDownshiftDefault = true; if (isOpen) toggleMenu(); } if (!inputValue && highlightedIndex == -1 && event.key == "Enter") { if (!isOpen) toggleMenu(); selectItem(null); event.preventDownshiftDefault = true; if (event.currentTarget.ariaExpanded === "false") openMenu(); } if (typeahead && event.key === "Tab") { if (!isOpen) return; const matchingItem = items.find((item) => !isDisabledItem(item) && itemToString(item).toLowerCase().startsWith(inputValue.toLowerCase())); if (matchingItem) { downshiftSetInputValue(itemToString(matchingItem)); selectItem(matchingItem); } } } }), ...rest, ...readOnlyEventHandlers, readOnly, "aria-describedby": ariaDescribedBy }), normalizedProps.invalid && /* @__PURE__ */ jsx(WarningFilled, { className: `${prefix}--list-box__invalid-icon` }), normalizedProps.warn && /* @__PURE__ */ jsx(WarningAltFilled, { className: `${prefix}--list-box__invalid-icon ${prefix}--list-box__invalid-icon--warning` }), inputValue && /* @__PURE__ */ jsx(ListBoxSelection, { clearSelection: () => { setIsClearing(true); setInputValue(""); onChange({ selectedItem: null }); selectItem(null); committedCustomValueRef.current = ""; handleSelectionClear(); }, translateWithId, disabled: disabled || readOnly, onClearSelection: handleSelectionClear, selectionCount: 0 }), /* @__PURE__ */ jsx(ListBoxTrigger, { ...buttonProps, isOpen, translateWithId }) ] }), slug ? normalizedDecorator : decorator ? /* @__PURE__ */ jsx("div", { className: `${prefix}--list-box__inner-wrapper--decorator`, children: candidateIsAILabel ? normalizedDecorator : /* @__PURE__ */ jsx("span", { children: normalizedDecorator }) }) : "", /* @__PURE__ */ jsx(ListBox.Menu, { ...menuProps, children: isOpen ? filterItems(items, itemToString, inputValue).map((item, index) => { const title = item !== null && typeof item === "object" && "text" in item && itemToElement ? item.text?.toString() : itemToString(item); const itemProps = getItemProps({ item, index }); const disabled = itemProps["aria-disabled"]; const { "aria-disabled": unusedAriaDisabled, "aria-selected": unusedAriaSelected, ...modifiedItemProps } = itemProps; const isSelected = isEqual(currentSelectedItem, item); return /* @__PURE__ */ jsxs(ListBox.MenuItem, { isActive: isSelected, isHighlighted: highlightedIndex === index, title, disabled, ...modifiedItemProps, "aria-selected": isSelected, children: [itemToElement ? itemToElement(item) : itemToString(item), isSelected && /* @__PURE__ */ jsx(Checkmark, { className: `${prefix}--list-box__menu-item__selected-icon` })] }, itemProps.id); }) : null }) ] }), helperText && !normalizedProps.invalid && !normalizedProps.warn && !isFluid && /* @__PURE__ */ jsx(Text, { as: "div", id: helperTextId, className: helperClasses, children: helperText }) ] }); }); ComboBox.displayName = "ComboBox"; ComboBox.propTypes = { allowCustomValue: PropTypes.bool, ["aria-label"]: PropTypes.string, ariaLabel: deprecate(PropTypes.string, "This prop syntax has been deprecated. Please use the new `aria-label`."), autoAlign: PropTypes.bool, className: PropTypes.string, decorator: PropTypes.node, direction: PropTypes.oneOf(["top", "bottom"]), disabled: PropTypes.bool, downshiftProps: PropTypes.object, downshiftActions: PropTypes.exact({ current: PropTypes.any }), helperText: PropTypes.node, id: PropTypes.string.isRequired, initialSelectedItem: PropTypes.oneOfType([ PropTypes.object, PropTypes.string, PropTypes.number ]), invalid: PropTypes.bool, invalidText: PropTypes.node, itemToElement: PropTypes.func, itemToString: PropTypes.func, items: PropTypes.array.isRequired, light: deprecate(PropTypes.bool, "The `light` prop for `Combobox` has been deprecated in favor of the new `Layer` component. It will be removed in the next major release."), onChange: PropTypes.func.isRequired, onInputChange: PropTypes.func, onToggleClick: PropTypes.func, placeholder: PropTypes.string, readOnly: PropTypes.bool, selectedItem: PropTypes.oneOfType([ PropTypes.object, PropTypes.string, PropTypes.number ]), shouldFilterItem: PropTypes.func, size: ListBoxSizePropType, slug: deprecate(PropTypes.node, "The `slug` prop has been deprecated and will be removed in the next major version. Use the decorator prop instead."), titleText: PropTypes.node, translateWithId: PropTypes.func, typeahead: PropTypes.bool, warn: PropTypes.bool, warnText: PropTypes.node, inputProps: PropTypes.object }; //#endregion export { ComboBox as default };