UNPKG

@carbon/react

Version:

React components for the Carbon Design System

615 lines (613 loc) 22.6 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 { Delete, End, Enter, Escape, Home, Space, Tab } from "../../internal/keyboard/keys.js"; import { match } from "../../internal/keyboard/match.js"; import useIsomorphicEffect from "../../internal/useIsomorphicEffect.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 { useNormalizedInputProps } from "../../internal/useNormalizedInputProps.js"; import { AILabel } from "../AILabel/index.js"; import Checkbox_default from "../Checkbox/index.js"; import { ListBoxSizePropType, ListBoxTypePropType } 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 { defaultFilterItems } from "./filter.js"; import { sortingPropTypes } from "./MultiSelectPropTypes.js"; import { defaultCompareItems, defaultSortItems } from "./tools/sorting.js"; import { useSelection } from "../../internal/Selection.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 { WarningAltFilled, WarningFilled } from "@carbon/icons-react"; import { autoUpdate, flip, hide, size, useFloating } from "@floating-ui/react"; import Downshift, { useCombobox, useMultipleSelection } from "downshift"; import isEqual from "react-fast-compare"; //#region src/components/MultiSelect/FilterableMultiSelect.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, ItemClick, MenuMouseLeave, InputKeyDownArrowUp, InputKeyDownArrowDown, ItemMouseMove, InputClick, ToggleButtonClick, FunctionToggleMenu, InputChange, InputKeyDownEscape, FunctionSetHighlightedIndex } = useCombobox.stateChangeTypes; const { SelectedItemKeyDownBackspace, SelectedItemKeyDownDelete, DropdownKeyDownBackspace, FunctionRemoveSelectedItem } = useMultipleSelection.stateChangeTypes; const FilterableMultiSelect = forwardRef(function FilterableMultiSelect({ autoAlign = false, className: containerClassName, clearSelectionDescription = "Total items selected: ", clearSelectionText = "To clear selection, press Delete or Backspace", compareItems = defaultCompareItems, decorator, direction = "bottom", disabled = false, downshiftProps, filterItems = defaultFilterItems, helperText, hideLabel, id, initialSelectedItems = [], invalid = false, invalidText, items, itemToElement: ItemToElement, itemToString = defaultItemToString, light, locale = "en", onInputValueChange, open = false, onChange, onMenuChange, placeholder, readOnly, titleText, type, selectionFeedback = "top-after-reopen", selectedItems: selected, size: size$1, sortItems = defaultSortItems, translateWithId, useTitleInItem, warn = false, warnText, slug, inputProps }, ref) { const { isFluid } = useContext(FormContext); const isFirstRender = useRef(true); const [isOpen, setIsOpen] = useState(!!open); const [inputValue, setInputValue] = useState(""); const [topItems, setTopItems] = useState(initialSelectedItems ?? []); const [inputFocused, setInputFocused] = useState(false); const filteredItems = useMemo(() => filterItems(items, { itemToString, inputValue }), [ items, inputValue, itemToString, filterItems ]); const nonSelectAllItems = useMemo(() => filteredItems.filter((item) => !item.isSelectAll), [filteredItems]); const selectAll = filteredItems.some((item) => item.isSelectAll); const { selectedItems: controlledSelectedItems, onItemChange, clearSelection, toggleAll } = useSelection({ disabled, initialSelectedItems, onChange, selectedItems: selected, selectAll, filteredItems }); const selectAllStatus = useMemo(() => { const selectable = nonSelectAllItems.filter((item) => !item.disabled); const nonSelectedCount = selectable.filter((item) => !controlledSelectedItems.some((sel) => isEqual(sel, item))).length; const totalCount = selectable.length; return { checked: totalCount > 0 && nonSelectedCount === 0, indeterminate: nonSelectedCount > 0 && nonSelectedCount < totalCount }; }, [controlledSelectedItems, nonSelectAllItems]); const handleSelectAllClick = useCallback(() => { const selectable = nonSelectAllItems.filter((i) => !i.disabled); const { checked, indeterminate } = selectAllStatus; if (checked || indeterminate) toggleAll(controlledSelectedItems.filter((sel) => !filteredItems.some((e) => isEqual(e, sel)))); else { const toSelect = selectable.filter((e) => !controlledSelectedItems.some((sel) => isEqual(sel, e))); toggleAll([...controlledSelectedItems, ...toSelect]); } }, [ nonSelectAllItems, selectAllStatus, controlledSelectedItems, toggleAll ]); const { refs, floatingStyles, middlewareData } = useFloating(autoAlign ? { placement: direction, strategy: "fixed", middleware: [ flip({ crossAxis: false }), size({ apply({ rects, elements }) { Object.assign(elements.floating.style, { width: `${rects.reference.width}px` }); } }), hide() ], whileElementsMounted: autoUpdate } : {}); useIsomorphicEffect(() => { if (autoAlign) { 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]; }); } }, [ autoAlign, floatingStyles, refs.floating, middlewareData, open ]); const textInput = useRef(null); const filterableMultiSelectInstanceId = useId(); const prefix = usePrefix(); useEffect(() => { setIsOpen(open); }, [open]); const sortedItems = useMemo(() => { const selectAllItem = items.find((item) => item.isSelectAll); const selectableRealItems = nonSelectAllItems.filter((item) => !item.disabled); const sortedReal = sortItems(nonSelectAllItems, { selectedItems: { top: controlledSelectedItems, fixed: [], "top-after-reopen": topItems }[selectionFeedback], itemToString, compareItems, locale }); if (selectAllItem && selectableRealItems.length > 0) return [selectAllItem, ...sortedReal]; return sortedReal; }, [ items, inputValue, controlledSelectedItems, topItems, selectionFeedback, itemToString, compareItems, locale, sortItems, nonSelectAllItems ]); const normalizedProps = useNormalizedInputProps({ id, disabled, readOnly, invalid, warn }); const inline = type === "inline"; const showWarning = normalizedProps.warn; const showHelperText = !normalizedProps.warn && !normalizedProps.invalid; const wrapperClasses = classNames(`${prefix}--multi-select__wrapper`, `${prefix}--multi-select--filterable__wrapper`, `${prefix}--list-box__wrapper`, containerClassName, { [`${prefix}--multi-select__wrapper--inline`]: inline, [`${prefix}--list-box__wrapper--inline`]: inline, [`${prefix}--multi-select__wrapper--inline--invalid`]: inline && normalizedProps.invalid, [`${prefix}--list-box__wrapper--inline--invalid`]: inline && normalizedProps.invalid, [`${prefix}--list-box--up`]: direction === "top", [`${prefix}--list-box__wrapper--fluid--invalid`]: isFluid && normalizedProps.invalid, [`${prefix}--list-box__wrapper--slug`]: slug, [`${prefix}--list-box__wrapper--decorator`]: decorator, [`${prefix}--autoalign`]: autoAlign }); const hasHelper = typeof helperText !== "undefined" && helperText !== null; const helperId = !hasHelper ? void 0 : `filterablemultiselect-helper-text-${filterableMultiSelectInstanceId}`; const labelId = `${id}-label`; const titleClasses = classNames({ [`${prefix}--label`]: true, [`${prefix}--label--disabled`]: disabled, [`${prefix}--visually-hidden`]: hideLabel }); const helperClasses = classNames({ [`${prefix}--form__helper-text`]: true, [`${prefix}--form__helper-text--disabled`]: disabled }); const inputClasses = classNames({ [`${prefix}--text-input`]: true, [`${prefix}--text-input--empty`]: !inputValue, [`${prefix}--text-input--light`]: light }); const helper = hasHelper && /* @__PURE__ */ jsx("div", { id: helperId, className: helperClasses, children: helperText }); const menuId = `${id}__menu`; const inputId = `${id}-input`; useEffect(() => { if (!isOpen) setTopItems(controlledSelectedItems); }, [ controlledSelectedItems, isOpen, setTopItems ]); const validateHighlightFocus = () => { if (controlledSelectedItems.length > 0) setHighlightedIndex(0); }; function handleMenuChange(forceIsOpen) { if (!readOnly) { setIsOpen(forceIsOpen ?? !isOpen); validateHighlightFocus(); } } useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false; if (open) onMenuChange?.(isOpen); } else onMenuChange?.(isOpen); }, [ isOpen, onMenuChange, open ]); useEffect(() => { const handleClickOutside = (event) => { const target = event.target; if (!(target instanceof Node)) return; const wrapper = document.getElementById(id)?.closest(`.${prefix}--multi-select__wrapper`); if (wrapper && !wrapper.contains(target)) { if (isOpen || inputFocused) { setIsOpen(false); setInputFocused(false); setInputValue(""); } } }; if (inputFocused || isOpen) document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, [isOpen, inputFocused]); const { getToggleButtonProps, getLabelProps, getMenuProps, getInputProps, highlightedIndex, setHighlightedIndex, getItemProps, openMenu, isOpen: isMenuOpen } = useCombobox({ isOpen, items: sortedItems, itemToString, id, labelId, menuId, inputId, inputValue, stateReducer, isItemDisabled(item) { return item?.disabled; } }); function stateReducer(state, actionAndChanges) { const { type, props, changes } = actionAndChanges; const { highlightedIndex } = changes; if (changes.isOpen && !isOpen) setTopItems(controlledSelectedItems); switch (type) { case InputKeyDownEnter: if (sortedItems.length === 0) return changes; if (changes.selectedItem && changes.selectedItem.disabled !== true) if (changes.selectedItem.isSelectAll) handleSelectAllClick(); else onItemChange(changes.selectedItem); setHighlightedIndex(changes.selectedItem); return { ...changes, highlightedIndex: state.highlightedIndex }; case ItemClick: if (changes.selectedItem.isSelectAll) handleSelectAllClick(); else onItemChange(changes.selectedItem); setHighlightedIndex(changes.selectedItem); return changes; case InputBlur: case InputKeyDownEscape: setIsOpen(false); return changes; case FunctionToggleMenu: case ToggleButtonClick: validateHighlightFocus(); if (changes.isOpen && !changes.selectedItem) return { ...changes }; return { ...changes, highlightedIndex: controlledSelectedItems.length > 0 ? 0 : -1 }; case InputChange: if (onInputValueChange) onInputValueChange(changes); setInputValue(changes.inputValue ?? ""); setIsOpen(true); return { ...changes, highlightedIndex: 0 }; case InputClick: setIsOpen(changes.isOpen || false); validateHighlightFocus(); if (changes.isOpen && !changes.selectedItem) return { ...changes }; return { ...changes, isOpen: false, highlightedIndex: controlledSelectedItems.length > 0 ? 0 : -1 }; case MenuMouseLeave: return { ...changes, highlightedIndex: state.highlightedIndex }; case InputKeyDownArrowUp: case InputKeyDownArrowDown: if (InputKeyDownArrowDown === type && !isOpen) { setIsOpen(true); return { ...changes, highlightedIndex: 0 }; } if (highlightedIndex > -1) { const itemArray = document.querySelectorAll(`li.${prefix}--list-box__menu-item[role="option"]`); props.scrollIntoView(itemArray[highlightedIndex]); } if (highlightedIndex === -1) return { ...changes, highlightedIndex: 0 }; return changes; case ItemMouseMove: return { ...changes, highlightedIndex: state.highlightedIndex }; case FunctionSetHighlightedIndex: if (!isOpen) return { ...changes, highlightedIndex: 0 }; else return { ...changes, highlightedIndex: props.items.indexOf(highlightedIndex) }; default: return changes; } } const { getDropdownProps } = useMultipleSelection({ activeIndex: highlightedIndex, initialSelectedItems, selectedItems: controlledSelectedItems, onStateChange(changes) { switch (changes.type) { case SelectedItemKeyDownBackspace: case SelectedItemKeyDownDelete: case DropdownKeyDownBackspace: case FunctionRemoveSelectedItem: clearSelection(); break; } }, ...downshiftProps }); useEffect(() => { if (isOpen && !isMenuOpen) openMenu(); }); function clearInputValue(event) { const value = textInput.current?.value; if (value?.length === 1 || event && "key" in event && match(event, Escape)) setInputValue(""); else setInputValue(value ?? ""); if (textInput.current) textInput.current.focus(); } const candidate = slug ?? decorator; const candidateIsAILabel = isComponentElement(candidate, AILabel); const normalizedDecorator = candidateIsAILabel ? cloneElement(candidate, { size: "mini" }) : candidate; const selectedItemsLength = controlledSelectedItems.filter((item) => !item.isSelectAll).length; const className = classNames(`${prefix}--multi-select`, `${prefix}--combo-box`, `${prefix}--multi-select--filterable`, { [`${prefix}--multi-select--invalid`]: normalizedProps.invalid, [`${prefix}--multi-select--invalid--focused`]: inputFocused && normalizedProps.invalid, [`${prefix}--multi-select--open`]: isOpen, [`${prefix}--multi-select--inline`]: inline, [`${prefix}--multi-select--selected`]: controlledSelectedItems?.length > 0, [`${prefix}--multi-select--filterable--input-focused`]: inputFocused, [`${prefix}--multi-select--readonly`]: readOnly, [`${prefix}--multi-select--selectall`]: selectAll }); const labelProps = getLabelProps(); const buttonProps = getToggleButtonProps({ disabled, onClick: () => { handleMenuChange(!isOpen); textInput.current?.focus(); }, onMouseUp(event) { if (isOpen) event.stopPropagation(); } }); const inputProp = getInputProps(getDropdownProps({ "aria-controls": isOpen ? menuId : void 0, "aria-describedby": helperText && showHelperText ? helperId : void 0, "aria-haspopup": "listbox", "aria-labelledby": void 0, disabled, placeholder, preventKeyAction: isOpen, ...inputProps, onClick: () => handleMenuChange(true), onKeyDown(event) { const $input = event.target; const $value = $input.value; if (match(event, Space)) event.stopPropagation(); if (match(event, Enter)) handleMenuChange(true); if (!disabled) { if (match(event, Delete) || match(event, Escape)) { if (isOpen) { handleMenuChange(true); clearInputValue(event); event.stopPropagation(); } else if (!isOpen) { clearInputValue(event); clearSelection(); event.stopPropagation(); } } } if (match(event, Tab)) handleMenuChange(false); if (match(event, Home)) $input.setSelectionRange(0, 0); if (match(event, End)) $input.setSelectionRange($value.length, $value.length); }, onFocus: () => setInputFocused(true), onBlur: () => { setInputFocused(false); setInputValue(""); } })); const menuProps = useMemo(() => getMenuProps({ ref: autoAlign ? refs.setFloating : null, hidden: !isOpen }, { suppressRefError: true }), [ autoAlign, getMenuProps, isOpen, refs.setFloating ]); const mergedRef = mergeRefs(textInput, inputProp.ref); const readOnlyEventHandlers = readOnly ? { onClick: (evt) => { evt.preventDefault(); if (textInput.current) textInput.current.focus(); }, onKeyDown: (evt) => { if ([ "ArrowDown", "ArrowUp", " ", "Enter" ].includes(evt.key)) evt.preventDefault(); } } : {}; const clearSelectionContent = controlledSelectedItems.length > 0 ? `${clearSelectionDescription} ${controlledSelectedItems.length}. ${clearSelectionText}.` : `${clearSelectionDescription} 0.`; return /* @__PURE__ */ jsxs("div", { className: wrapperClasses, children: [ titleText ? /* @__PURE__ */ jsxs("label", { className: titleClasses, ...labelProps, children: [titleText, /* @__PURE__ */ jsx("span", { className: `${prefix}--visually-hidden`, children: clearSelectionContent })] }) : null, /* @__PURE__ */ jsxs(ListBox, { className, disabled, light, ref, id, invalid: normalizedProps.invalid, invalidText, warn: normalizedProps.warn, warnText, isOpen: !readOnly && isOpen, size: size$1, children: [ /* @__PURE__ */ jsxs("div", { className: `${prefix}--list-box__field`, ref: autoAlign ? refs.setReference : null, children: [ controlledSelectedItems.length > 0 && /* @__PURE__ */ jsx(ListBoxSelection, { readOnly, clearSelection: () => { clearSelection(); if (textInput.current) textInput.current.focus(); }, selectionCount: selectedItemsLength, translateWithId, disabled }), /* @__PURE__ */ jsx("input", { className: inputClasses, ...inputProp, ref: mergedRef, ...readOnlyEventHandlers, readOnly }), normalizedProps.invalid && /* @__PURE__ */ jsx(WarningFilled, { className: `${prefix}--list-box__invalid-icon` }), showWarning && /* @__PURE__ */ jsx(WarningAltFilled, { className: `${prefix}--list-box__invalid-icon ${prefix}--list-box__invalid-icon--warning` }), inputValue && /* @__PURE__ */ jsx(ListBoxSelection, { clearSelection: clearInputValue, disabled, translateWithId, readOnly, onMouseUp: (event) => { event.stopPropagation(); } }), /* @__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 ? sortedItems.map((item, index) => { let isChecked; let isIndeterminate = false; if (item.isSelectAll) { isChecked = selectAllStatus.checked; isIndeterminate = selectAllStatus.indeterminate; } else isChecked = controlledSelectedItems.filter((selected) => isEqual(selected, item)).length > 0; const itemProps = getItemProps({ item, ["aria-selected"]: isChecked }); const itemText = itemToString(item); const disabled = itemProps["aria-disabled"]; const { "aria-disabled": unusedAriaDisabled, ...modifiedItemProps } = itemProps; return /* @__PURE__ */ jsx(ListBox.MenuItem, { "aria-label": itemText, "aria-checked": isIndeterminate ? "mixed" : isChecked, isActive: isChecked && !item["isSelectAll"], isHighlighted: highlightedIndex === index, title: itemText, disabled, ...modifiedItemProps, children: /* @__PURE__ */ jsx("div", { className: `${prefix}--checkbox-wrapper`, children: /* @__PURE__ */ jsx(Checkbox_default, { id: `${itemProps.id}-item`, labelText: ItemToElement ? /* @__PURE__ */ jsx(ItemToElement, { ...item }, itemProps.id) : itemText, checked: isChecked, title: useTitleInItem ? itemText : void 0, indeterminate: isIndeterminate, disabled, tabIndex: -1 }) }) }, itemProps.id); }) : null }) ] }), !inline && showHelperText ? helper : null ] }); }); FilterableMultiSelect.displayName = "FilterableMultiSelect"; FilterableMultiSelect.propTypes = { ["aria-label"]: deprecate(PropTypes.string, "ariaLabel / aria-label props are no longer required for FilterableMultiSelect"), ariaLabel: deprecate(PropTypes.string, "ariaLabel / aria-label props are no longer required for FilterableMultiSelect"), autoAlign: PropTypes.bool, clearSelectionDescription: PropTypes.string, clearSelectionText: PropTypes.string, decorator: PropTypes.node, filterItems: PropTypes.func, direction: PropTypes.oneOf(["top", "bottom"]), disabled: PropTypes.bool, downshiftProps: PropTypes.shape(Downshift.propTypes), hideLabel: PropTypes.bool, id: PropTypes.string.isRequired, initialSelectedItems: PropTypes.array, invalid: PropTypes.bool, invalidText: PropTypes.node, itemToElement: PropTypes.func, itemToString: PropTypes.func, items: PropTypes.array.isRequired, light: deprecate(PropTypes.bool, "The `light` prop for `FilterableMultiSelect` has been deprecated in favor of the new `Layer` component. It will be removed in the next major release."), locale: PropTypes.string, onChange: PropTypes.func, onInputValueChange: PropTypes.func, onMenuChange: PropTypes.func, open: PropTypes.bool, placeholder: PropTypes.string, selectionFeedback: PropTypes.oneOf([ "top", "fixed", "top-after-reopen" ]), 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."), ...sortingPropTypes, titleText: PropTypes.node, translateWithId: PropTypes.func, type: ListBoxTypePropType, useTitleInItem: PropTypes.bool, warn: PropTypes.bool, warnText: PropTypes.node, inputProps: PropTypes.object }; //#endregion export { FilterableMultiSelect };