@carbon/react
Version:
React components for the Carbon Design System
635 lines (633 loc) • 24.9 kB
JavaScript
/**
* 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 };