UNPKG

@carbon/react

Version:

React components for the Carbon Design System

357 lines (355 loc) 14.3 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 { 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, ListBoxTypePropType } from "../ListBox/ListBoxPropTypes.js"; import { FormContext } from "../FluidForm/FormContext.js"; import ListBox from "../ListBox/index.js"; import { mergeRefs } from "../../tools/mergeRefs.js"; import classNames from "classnames"; import React, { cloneElement, isValidElement, 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, size, useFloating } from "@floating-ui/react"; import { useSelect } from "downshift"; //#region src/components/Dropdown/Dropdown.tsx /** * Copyright IBM Corp. 2022, 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 { ItemMouseMove, MenuMouseLeave, ToggleButtonBlur, FunctionCloseMenu } = useSelect.stateChangeTypes; /** * Custom state reducer for `useSelect` in Downshift, providing control over * state changes. * * This function is called each time `useSelect` updates its internal state or * triggers `onStateChange`. It allows for fine-grained control of state * updates by modifying or overriding the default changes from Downshift's * reducer. * https://github.com/downshift-js/downshift/tree/master/src/hooks/useSelect#statereducer * * @param {Object} state - The current full state of the Downshift component. * @param {Object} actionAndChanges - Contains the action type and proposed * changes from the default Downshift reducer. * @param {Object} actionAndChanges.changes - Suggested state changes. * @param {string} actionAndChanges.type - The action type for the state * change (e.g., item selection). * @returns {Object} - The modified state based on custom logic or default * changes if no custom logic applies. */ function stateReducer(state, actionAndChanges) { const { changes, type } = actionAndChanges; switch (type) { case ItemMouseMove: return state; case MenuMouseLeave: if (changes.highlightedIndex === state.highlightedIndex) return state; return changes; case ToggleButtonBlur: case FunctionCloseMenu: return { ...changes, selectedItem: state.selectedItem }; default: return changes; } } const Dropdown = React.forwardRef(({ autoAlign = false, className: containerClassName, decorator, disabled = false, direction = "bottom", items: itemsProp, label, ["aria-label"]: ariaLabel, ariaLabel: deprecatedAriaLabel, itemToString = defaultItemToString, itemToElement = null, renderSelectedItem, type = "default", size: size$1, onChange, id, titleText = "", hideLabel, helperText = "", translateWithId, light, invalid, invalidText, warn, warnText, initialSelectedItem, selectedItem: controlledSelectedItem, downshiftProps, readOnly, slug, ...other }, ref) => { const enableFloatingStyles = useFeatureFlag("enable-v12-dynamic-floating-styles"); const { refs, floatingStyles, middlewareData } = useFloating(enableFloatingStyles || autoAlign ? { placement: direction, strategy: "fixed", middleware: [ size({ apply({ rects, elements }) { Object.assign(elements.floating.style, { width: `${rects.reference.width}px` }); } }), autoAlign && flip(), autoAlign && hide() ], whileElementsMounted: autoUpdate } : {}); useEffect(() => { if (enableFloatingStyles || 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]; }); } }, [ floatingStyles, autoAlign, refs.floating ]); const prefix = usePrefix(); const { isFluid } = useContext(FormContext); const onSelectedItemChange = useCallback(({ selectedItem }) => { if (onChange) onChange({ selectedItem: selectedItem ?? null }); }, [onChange]); const isItemDisabled = useCallback((item) => { return item !== null && typeof item === "object" && "disabled" in item && item.disabled === true; }, []); const onHighlightedIndexChange = useCallback((changes) => { const { highlightedIndex } = changes; if (highlightedIndex !== void 0 && highlightedIndex > -1) { const highlightedItem = document.querySelectorAll(`li.${prefix}--list-box__menu-item[role="option"]`)[highlightedIndex]; if (highlightedItem) highlightedItem.scrollIntoView({ behavior: "smooth", block: "nearest" }); } }, [prefix]); const items = useMemo(() => itemsProp, [itemsProp]); const selectProps = useMemo(() => ({ items, itemToString, initialSelectedItem, onSelectedItemChange, stateReducer, isItemDisabled, onHighlightedIndexChange, ...downshiftProps }), [ items, itemToString, initialSelectedItem, onSelectedItemChange, stateReducer, isItemDisabled, onHighlightedIndexChange, downshiftProps ]); if (controlledSelectedItem !== void 0) selectProps.selectedItem = controlledSelectedItem; const { isOpen, getToggleButtonProps, getLabelProps, getMenuProps, getItemProps, selectedItem, highlightedIndex } = useSelect(selectProps); const inline = type === "inline"; const normalizedProps = useNormalizedInputProps({ id, readOnly, disabled: disabled ?? false, invalid: invalid ?? false, invalidText, warn: warn ?? false, warnText }); const [isFocused, setIsFocused] = useState(false); const className = classNames(`${prefix}--dropdown`, { [`${prefix}--dropdown--invalid`]: normalizedProps.invalid, [`${prefix}--dropdown--warning`]: normalizedProps.warn, [`${prefix}--dropdown--open`]: isOpen, [`${prefix}--dropdown--focus`]: isFocused, [`${prefix}--dropdown--inline`]: inline, [`${prefix}--dropdown--disabled`]: normalizedProps.disabled, [`${prefix}--dropdown--light`]: light, [`${prefix}--dropdown--readonly`]: readOnly, [`${prefix}--dropdown--${size$1}`]: size$1, [`${prefix}--list-box--up`]: direction === "top", [`${prefix}--autoalign`]: autoAlign }); const titleClasses = classNames(`${prefix}--label`, { [`${prefix}--label--disabled`]: normalizedProps.disabled, [`${prefix}--visually-hidden`]: hideLabel }); const helperClasses = classNames(`${prefix}--form__helper-text`, { [`${prefix}--form__helper-text--disabled`]: normalizedProps.disabled }); const wrapperClasses = classNames(`${prefix}--dropdown__wrapper`, `${prefix}--list-box__wrapper`, containerClassName, { [`${prefix}--dropdown__wrapper--inline`]: inline, [`${prefix}--list-box__wrapper--inline`]: inline, [`${prefix}--dropdown__wrapper--inline--invalid`]: inline && normalizedProps.invalid, [`${prefix}--list-box__wrapper--inline--invalid`]: inline && normalizedProps.invalid, [`${prefix}--list-box__wrapper--fluid--invalid`]: isFluid && normalizedProps.invalid, [`${prefix}--list-box__wrapper--slug`]: slug, [`${prefix}--list-box__wrapper--decorator`]: decorator }); const toggleButtonProps = getToggleButtonProps({ "aria-label": ariaLabel || deprecatedAriaLabel }); const helper = helperText && !isFluid ? /* @__PURE__ */ jsx("div", { id: normalizedProps.helperId, className: helperClasses, children: helperText }) : null; const handleFocus = (evt) => { setIsFocused(evt.type === "focus" && !selectedItem); }; const buttonRef = useRef(null); const mergedRef = mergeRefs(toggleButtonProps.ref, ref, buttonRef); const [currTimer, setCurrTimer] = useState(); const [isTyping, setIsTyping] = useState(false); const onKeyDownHandler = useCallback((evt) => { if (![ "ArrowDown", "ArrowUp", " ", "Enter" ].includes(evt.key)) { setIsTyping(true); if (currTimer) clearTimeout(currTimer); setCurrTimer(setTimeout(() => { setIsTyping(false); }, 3e3)); } else if (isTyping && evt.key === " ") { if (currTimer) clearTimeout(currTimer); setCurrTimer(setTimeout(() => { setIsTyping(false); }, 3e3)); } if (["ArrowDown"].includes(evt.key)) setIsFocused(false); if (["Enter"].includes(evt.key) && !selectedItem && !isOpen) setIsFocused(true); if (toggleButtonProps.onKeyDown && (evt.key !== "ArrowUp" || isOpen && evt.key === "ArrowUp")) toggleButtonProps.onKeyDown(evt); }, [ isTyping, currTimer, toggleButtonProps ]); const readOnlyEventHandlers = useMemo(() => { if (readOnly) return { onClick: (evt) => { evt.preventDefault(); buttonRef.current?.focus(); }, onKeyDown: (evt) => { if ([ "ArrowDown", "ArrowUp", " ", "Enter" ].includes(evt.key)) evt.preventDefault(); } }; else return { onKeyDown: onKeyDownHandler }; }, [readOnly, onKeyDownHandler]); const menuProps = useMemo(() => getMenuProps({ ref: enableFloatingStyles || autoAlign ? refs.setFloating : null }), [ autoAlign, getMenuProps, refs.setFloating, enableFloatingStyles ]); const candidate = slug ?? decorator; const normalizedDecorator = isComponentElement(candidate, AILabel) ? cloneElement(candidate, { size: "mini" }) : candidate; const allLabelProps = getLabelProps(); const labelProps = isValidElement(titleText) ? { id: allLabelProps.id } : allLabelProps; return /* @__PURE__ */ jsxs("div", { className: wrapperClasses, ...other, children: [ titleText && /* @__PURE__ */ jsx("label", { className: titleClasses, ...labelProps, children: titleText }), /* @__PURE__ */ jsxs(ListBox, { onFocus: handleFocus, onBlur: handleFocus, size: size$1, className, invalid: normalizedProps.invalid, invalidText, invalidTextId: normalizedProps.invalidId, warn: normalizedProps.warn, warnText, warnTextId: normalizedProps.warnId, light, isOpen, ref: enableFloatingStyles || autoAlign ? refs.setReference : null, id, children: [ 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` }), /* @__PURE__ */ jsxs("button", { type: "button", className: `${prefix}--list-box__field`, disabled: normalizedProps.disabled, "aria-disabled": readOnly ? true : void 0, "aria-describedby": !inline && !normalizedProps.invalid && !normalizedProps.warn && helper ? normalizedProps.helperId : normalizedProps.invalid ? normalizedProps.invalidId : normalizedProps.warn ? normalizedProps.warnId : void 0, title: selectedItem && itemToString !== void 0 ? itemToString(selectedItem) : defaultItemToString(label), ...toggleButtonProps, ...readOnlyEventHandlers, ref: mergedRef, children: [/* @__PURE__ */ jsx("span", { className: `${prefix}--list-box__label`, children: selectedItem ? renderSelectedItem ? renderSelectedItem(selectedItem) : itemToString(selectedItem) : label }), /* @__PURE__ */ jsx(ListBox.MenuIcon, { isOpen, translateWithId })] }), slug ? normalizedDecorator : decorator ? /* @__PURE__ */ jsx("div", { className: `${prefix}--list-box__inner-wrapper--decorator`, children: normalizedDecorator }) : "", /* @__PURE__ */ jsx(ListBox.Menu, { ...menuProps, children: isOpen && items.map((item, index) => { const itemProps = getItemProps({ item, index }); const title = itemToString(item); return /* @__PURE__ */ jsxs(ListBox.MenuItem, { isActive: selectedItem === item, isHighlighted: highlightedIndex === index, title, disabled: itemProps["aria-disabled"], ...itemProps, children: [itemToElement ? itemToElement(item) : itemToString(item), selectedItem === item && /* @__PURE__ */ jsx(Checkmark, { className: `${prefix}--list-box__menu-item__selected-icon` })] }, itemProps.id); }) }) ] }), !inline && !isFluid && !normalizedProps.validation && helper ] }); }); Dropdown.displayName = "Dropdown"; Dropdown.propTypes = { ["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, helperText: PropTypes.node, hideLabel: PropTypes.bool, 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, label: PropTypes.node.isRequired, light: deprecate(PropTypes.bool, "The `light` prop for `Dropdown` has been deprecated in favor of the new `Layer` component. It will be removed in the next major release."), onChange: PropTypes.func, readOnly: PropTypes.bool, renderSelectedItem: PropTypes.func, selectedItem: PropTypes.oneOfType([ PropTypes.object, PropTypes.string, PropTypes.number ]), size: ListBoxSizePropType, slug: deprecate(PropTypes.node, "The `slug` prop for `Dropdown` has been deprecated in favor of the new `decorator` prop. It will be removed in the next major release."), titleText: PropTypes.node.isRequired, translateWithId: PropTypes.func, type: ListBoxTypePropType, warn: PropTypes.bool, warnText: PropTypes.node }; //#endregion export { Dropdown as default };