UNPKG

@carbon/react

Version:

React components for the Carbon Design System

555 lines (542 loc) 21.1 kB
/** * Copyright IBM Corp. 2016, 2023 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var _rollupPluginBabelHelpers = require('../../_virtual/_rollupPluginBabelHelpers.js'); var React = require('react'); var Downshift = require('downshift'); var cx = require('classnames'); var PropTypes = require('prop-types'); var iconsReact = require('@carbon/icons-react'); var index$2 = require('../ListBox/index.js'); var mergeRefs = require('../../tools/mergeRefs.js'); var deprecate = require('../../prop-types/deprecate.js'); var usePrefix = require('../../internal/usePrefix.js'); require('../FluidForm/FluidForm.js'); var FormContext = require('../FluidForm/FormContext.js'); var useNormalizedInputProps = require('../../internal/useNormalizedInputProps.js'); var react = require('@floating-ui/react'); var index = require('../FeatureFlags/index.js'); var index$1 = require('../AILabel/index.js'); var defaultItemToString = require('../../internal/defaultItemToString.js'); var utils = require('../../internal/utils.js'); var ListBoxPropTypes = require('../ListBox/ListBoxPropTypes.js'); const { ItemMouseMove, MenuMouseLeave, ToggleButtonBlur, FunctionCloseMenu } = Downshift.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: case MenuMouseLeave: if (changes.highlightedIndex === state.highlightedIndex) { // Prevent state update if highlightedIndex hasn't changed return state; } return changes; case ToggleButtonBlur: case FunctionCloseMenu: return { ...changes, selectedItem: state.selectedItem }; default: return changes; } } const Dropdown = /*#__PURE__*/React.forwardRef(({ autoAlign = false, className: containerClassName, decorator, disabled = false, direction = 'bottom', items: itemsProp, label, ['aria-label']: ariaLabel, ariaLabel: deprecatedAriaLabel, itemToString = defaultItemToString.defaultItemToString, itemToElement = null, renderSelectedItem, type = 'default', size, onChange, id, titleText = '', hideLabel, helperText = '', translateWithId, light, invalid, invalidText, warn, warnText, initialSelectedItem, selectedItem: controlledSelectedItem, downshiftProps, readOnly, slug, ...other }, ref) => { const enableFloatingStyles = index.useFeatureFlag('enable-v12-dynamic-floating-styles'); const { refs, floatingStyles, middlewareData } = react.useFloating(enableFloatingStyles || autoAlign ? { placement: direction, // The floating element is positioned relative to its nearest // containing block (usually the viewport). It will in many cases also // “break” the floating element out of a clipping ancestor. // https://floating-ui.com/docs/misc#clipping strategy: 'fixed', // Middleware order matters, arrow should be last middleware: [react.size({ apply({ rects, elements }) { Object.assign(elements.floating.style, { width: `${rects.reference.width}px` }); } }), autoAlign && react.flip(), autoAlign && react.hide()], whileElementsMounted: react.autoUpdate } : {} // When autoAlign is turned off & the `enable-v12-dynamic-floating-styles` feature flag is not // enabled, floating-ui will not be used ); React.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]; } }); } // eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452 }, [floatingStyles, autoAlign, refs.floating]); const prefix = usePrefix.usePrefix(); const { isFluid } = React.useContext(FormContext.FormContext); const onSelectedItemChange = React.useCallback(({ selectedItem }) => { if (onChange) { onChange({ selectedItem: selectedItem ?? null }); } }, [onChange]); // eslint-disable-next-line @typescript-eslint/no-unused-vars -- https://github.com/carbon-design-system/carbon/issues/20452 const isItemDisabled = React.useCallback((item, _index) => { const isObject = item !== null && typeof item === 'object'; return isObject && 'disabled' in item && item.disabled === true; }, []); const onHighlightedIndexChange = React.useCallback(changes => { const { highlightedIndex } = changes; if (highlightedIndex !== undefined && highlightedIndex > -1 && // eslint-disable-next-line valid-typeof , no-constant-binary-expression -- https://github.com/carbon-design-system/carbon/issues/20452 typeof window !== undefined) { const itemArray = document.querySelectorAll(`li.${prefix}--list-box__menu-item[role="option"]`); const highlightedItem = itemArray[highlightedIndex]; if (highlightedItem) { highlightedItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } }, [prefix]); const items = React.useMemo(() => itemsProp, [itemsProp]); const selectProps = React.useMemo(() => ({ items, itemToString, initialSelectedItem, onSelectedItemChange, stateReducer, isItemDisabled, onHighlightedIndexChange, ...downshiftProps }), // eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452 [items, itemToString, initialSelectedItem, onSelectedItemChange, stateReducer, isItemDisabled, onHighlightedIndexChange, downshiftProps]); // only set selectedItem if the prop is defined. Setting if it is undefined // will overwrite default selected items from useSelect if (controlledSelectedItem !== undefined) { selectProps.selectedItem = controlledSelectedItem; } const { isOpen, getToggleButtonProps, getLabelProps, getMenuProps, getItemProps, selectedItem, highlightedIndex } = Downshift.useSelect(selectProps); const inline = type === 'inline'; const normalizedProps = useNormalizedInputProps.useNormalizedInputProps({ id, readOnly, disabled: disabled ?? false, invalid: invalid ?? false, invalidText, warn: warn ?? false, warnText }); const [isFocused, setIsFocused] = React.useState(false); const className = cx(`${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}`]: size, [`${prefix}--list-box--up`]: direction === 'top', [`${prefix}--autoalign`]: autoAlign }); const titleClasses = cx(`${prefix}--label`, { [`${prefix}--label--disabled`]: normalizedProps.disabled, [`${prefix}--visually-hidden`]: hideLabel }); const helperClasses = cx(`${prefix}--form__helper-text`, { [`${prefix}--form__helper-text--disabled`]: normalizedProps.disabled }); const wrapperClasses = cx(`${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__*/React.createElement("div", { id: normalizedProps.helperId, className: helperClasses }, helperText) : null; const handleFocus = evt => { setIsFocused(evt.type === 'focus' && !selectedItem); }; const buttonRef = React.useRef(null); const mergedRef = mergeRefs.mergeRefs(toggleButtonProps.ref, ref, buttonRef); const [currTimer, setCurrTimer] = React.useState(); const [isTyping, setIsTyping] = React.useState(false); const onKeyDownHandler = React.useCallback(evt => { const navigationKeys = ['ArrowDown', 'ArrowUp', ' ', 'Enter']; // If the key is not a navigation key, the user is typing if (!navigationKeys.includes(evt.key)) { setIsTyping(true); // Reset the timer for typing timeout if (currTimer) { clearTimeout(currTimer); } setCurrTimer(setTimeout(() => { setIsTyping(false); }, 3000)); } else if (isTyping && evt.key === ' ') { // If user is typing and presses space, reset the timer if (currTimer) { clearTimeout(currTimer); } setCurrTimer(setTimeout(() => { setIsTyping(false); }, 3000)); } if (['ArrowDown'].includes(evt.key)) { setIsFocused(false); } if (['Enter'].includes(evt.key) && !selectedItem && !isOpen) { setIsFocused(true); } // For Dropdowns the arrow up key is only allowed if the Dropdown is open if (toggleButtonProps.onKeyDown && (evt.key !== 'ArrowUp' || isOpen && evt.key === 'ArrowUp')) { toggleButtonProps.onKeyDown(evt); } }, // eslint-disable-next-line react-hooks/exhaustive-deps -- https://github.com/carbon-design-system/carbon/issues/20452 [isTyping, currTimer, toggleButtonProps]); const readOnlyEventHandlers = React.useMemo(() => { if (readOnly) { return { onClick: evt => { // NOTE: does not prevent click evt.preventDefault(); // focus on the element as per readonly input behavior buttonRef.current?.focus(); }, onKeyDown: evt => { const selectAccessKeys = ['ArrowDown', 'ArrowUp', ' ', 'Enter']; // This prevents the select from opening for the above keys if (selectAccessKeys.includes(evt.key)) { evt.preventDefault(); } } }; } else { return { onKeyDown: onKeyDownHandler }; } }, [readOnly, onKeyDownHandler]); const menuProps = React.useMemo(() => getMenuProps({ ref: enableFloatingStyles || autoAlign ? refs.setFloating : null }), [autoAlign, getMenuProps, refs.setFloating, enableFloatingStyles]); // AILabel is always size `mini` const candidate = slug ?? decorator; const candidateIsAILabel = utils.isComponentElement(candidate, index$1.AILabel); const normalizedDecorator = candidateIsAILabel ? /*#__PURE__*/React.cloneElement(candidate, { size: 'mini' }) : candidate; const allLabelProps = getLabelProps(); const labelProps = /*#__PURE__*/React.isValidElement(titleText) ? { id: allLabelProps.id } : allLabelProps; return /*#__PURE__*/React.createElement("div", _rollupPluginBabelHelpers.extends({ className: wrapperClasses }, other), titleText && /*#__PURE__*/React.createElement("label", _rollupPluginBabelHelpers.extends({ className: titleClasses }, labelProps), titleText), /*#__PURE__*/React.createElement(index$2.default, { onFocus: handleFocus, onBlur: handleFocus, size: size, className: className, invalid: normalizedProps.invalid, invalidText: invalidText, invalidTextId: normalizedProps.invalidId, warn: normalizedProps.warn, warnText: warnText, warnTextId: normalizedProps.warnId, light: light, isOpen: isOpen, ref: enableFloatingStyles || autoAlign ? refs.setReference : null, id: id }, normalizedProps.invalid && /*#__PURE__*/React.createElement(iconsReact.WarningFilled, { className: `${prefix}--list-box__invalid-icon` }), normalizedProps.warn && /*#__PURE__*/React.createElement(iconsReact.WarningAltFilled, { className: `${prefix}--list-box__invalid-icon ${prefix}--list-box__invalid-icon--warning` }), /*#__PURE__*/React.createElement("button", _rollupPluginBabelHelpers.extends({ type: "button" // aria-expanded is already being passed through {...toggleButtonProps} , className: `${prefix}--list-box__field`, disabled: normalizedProps.disabled, "aria-disabled": readOnly ? true : undefined // aria-disabled to remain focusable , "aria-describedby": !inline && !normalizedProps.invalid && !normalizedProps.warn && helper ? normalizedProps.helperId : normalizedProps.invalid ? normalizedProps.invalidId : normalizedProps.warn ? normalizedProps.warnId : undefined, title: selectedItem && itemToString !== undefined ? itemToString(selectedItem) : defaultItemToString.defaultItemToString(label) }, toggleButtonProps, readOnlyEventHandlers, { ref: mergedRef }), /*#__PURE__*/React.createElement("span", { className: `${prefix}--list-box__label` }, selectedItem ? renderSelectedItem ? renderSelectedItem(selectedItem) : itemToString(selectedItem) : // eslint-disable-next-line @typescript-eslint/no-explicit-any -- https://github.com/carbon-design-system/carbon/issues/20452 label), /*#__PURE__*/React.createElement(index$2.default.MenuIcon, { isOpen: isOpen, translateWithId: translateWithId })), slug ? normalizedDecorator : decorator ? /*#__PURE__*/React.createElement("div", { className: `${prefix}--list-box__inner-wrapper--decorator` }, normalizedDecorator) : '', /*#__PURE__*/React.createElement(index$2.default.Menu, menuProps, isOpen && items.map((item, index) => { const itemProps = getItemProps({ item, index }); const title = itemToString(item); return /*#__PURE__*/React.createElement(index$2.default.MenuItem, _rollupPluginBabelHelpers.extends({ key: itemProps.id, isActive: selectedItem === item, isHighlighted: highlightedIndex === index, title: title, disabled: itemProps['aria-disabled'] }, itemProps), itemToElement ? itemToElement(item) : itemToString(item), selectedItem === item && /*#__PURE__*/React.createElement(iconsReact.Checkmark, { className: `${prefix}--list-box__menu-item__selected-icon` })); }))), !inline && !isFluid && !normalizedProps.validation && helper); }); // Workaround problems with forwardRef() and generics. In the long term, should stop using forwardRef(). // See https://stackoverflow.com/questions/58469229/react-with-typescript-generics-while-using-react-forwardref. Dropdown.displayName = 'Dropdown'; Dropdown.propTypes = { /** * 'aria-label' of the ListBox component. * Specify a label to be read by screen readers on the container node */ ['aria-label']: PropTypes.string, /** * Deprecated, please use `aria-label` instead. * Specify a label to be read by screen readers on the container note. */ ariaLabel: deprecate.deprecate(PropTypes.string, 'This prop syntax has been deprecated. Please use the new `aria-label`.'), /** * **Experimental**: Will attempt to automatically align the floating element * to avoid collisions with the viewport and being clipped by ancestor * elements. Requires React v17+ * @see https://github.com/carbon-design-system/carbon/issues/18714 */ autoAlign: PropTypes.bool, /** * Provide a custom className to be applied on the cds--dropdown node */ className: PropTypes.string, /** * **Experimental**: Provide a `decorator` component to be rendered inside the `Dropdown` component */ decorator: PropTypes.node, /** * Specify the direction of the dropdown. Can be either top or bottom. */ direction: PropTypes.oneOf(['top', 'bottom']), /** * Disable the control */ disabled: PropTypes.bool, /** * Additional props passed to Downshift. * * **Use with caution:** anything you define here overrides the components' * internal handling of that prop. Downshift APIs and internals are subject to * change, and in some cases they can not be shimmed by Carbon to shield you * from potentially breaking changes. */ downshiftProps: PropTypes.object, /** * Provide helper text that is used alongside the control label for * additional help */ helperText: PropTypes.node, /** * Specify whether the title text should be hidden or not */ hideLabel: PropTypes.bool, /** * Specify a custom `id` */ id: PropTypes.string.isRequired, /** * Allow users to pass in an arbitrary item or a string (in case their items are an array of strings) * from their collection that are pre-selected */ initialSelectedItem: PropTypes.oneOfType([PropTypes.object, PropTypes.string, PropTypes.number]), /** * Specify if the currently selected value is invalid. */ invalid: PropTypes.bool, /** * Message which is displayed if the value is invalid. */ invalidText: PropTypes.node, /** * Renders an item as a custom React node instead of a string. */ itemToElement: PropTypes.func, /** * Helper function passed to downshift that allows the library to render a * given item to a string label. By default, it extracts the `label` field * from a given item to serve as the item label in the list. */ itemToString: PropTypes.func, /** * We try to stay as generic as possible here to allow individuals to pass * in a collection of whatever kind of data structure they prefer */ items: PropTypes.array.isRequired, /** * Generic `label` that will be used as the textual representation of what * this field is for */ label: PropTypes.node.isRequired, /** * `true` to use the light version. */ light: deprecate.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` is a utility for this controlled component to communicate to a * consuming component what kind of internal state changes are occurring. */ onChange: PropTypes.func, /** * Whether or not the Dropdown is readonly */ readOnly: PropTypes.bool, /** * An optional callback to render the currently selected item as a react element instead of only * as a string. */ renderSelectedItem: PropTypes.func, /** * In the case you want to control the dropdown selection entirely. */ selectedItem: PropTypes.oneOfType([PropTypes.object, PropTypes.string, PropTypes.number]), /** * Specify the size of the ListBox. Currently supports either `sm`, `md` or `lg` as an option. */ size: ListBoxPropTypes.ListBoxSizePropType, /** * **Experimental**: Provide a `Slug` component to be rendered inside the `Dropdown` component */ slug: deprecate.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.'), /** * Provide the title text that will be read by a screen reader when * visiting this control */ titleText: PropTypes.node.isRequired, /** * Translates component strings using your i18n tool. */ translateWithId: PropTypes.func, /** * The dropdown type, `default` or `inline` */ type: ListBoxPropTypes.ListBoxTypePropType, /** * Specify whether the control is currently in warning state */ warn: PropTypes.bool, /** * Provide the text that is displayed when the control is in warning state */ warnText: PropTypes.node }; exports.default = Dropdown;