UNPKG

@mskcc/carbon-react

Version:

Carbon react components for the MSKCC DSM

556 lines (549 loc) 19 kB
/** * MSKCC 2021, 2024 */ import { extends as _extends } from '../../_virtual/_rollupPluginBabelHelpers.js'; import cx from 'classnames'; import Downshift, { useSelect } from 'downshift'; import isEqual from 'lodash.isequal'; import PropTypes from 'prop-types'; import React__default, { useContext, useRef, useState } from 'react'; import ListBox from '../ListBox/index.js'; import { sortingPropTypes } from './MultiSelectPropTypes.js'; import { useSelection } from '../../internal/Selection.js'; import setupGetInstanceId from '../../tools/setupGetInstanceId.js'; import mergeRefs from '../../tools/mergeRefs.js'; import deprecate from '../../prop-types/deprecate.js'; import { usePrefix } from '../../internal/usePrefix.js'; import '../FluidForm/FluidForm.js'; import { FormContext } from '../FluidForm/FormContext.js'; import { match } from '../../internal/keyboard/match.js'; import { ListBoxSize } from '../ListBox/ListBoxPropTypes.js'; import { Delete, Escape, Space, ArrowDown } from '../../internal/keyboard/keys.js'; var _span, _span2, _span3, _span4, _span5; const noop = () => {}; const getInstanceId = setupGetInstanceId(); const { ItemClick, MenuBlur, MenuKeyDownArrowDown, MenuKeyDownArrowUp, MenuKeyDownEscape, MenuKeyDownSpaceButton, ToggleButtonClick } = useSelect.stateChangeTypes; const defaultItemToString = item => { if (typeof item === 'string') { return item; } if (typeof item === 'number') { return `${item}`; } if (item !== null && typeof item === 'object' && 'label' in item && typeof item['label'] === 'string') { return item['label']; } return ''; }; const MultiSelect = /*#__PURE__*/React__default.forwardRef(function MultiSelect(_ref, ref) { let { countOnly = false, outsideItems = false, className: containerClassName, id, items, itemToElement, itemToString = defaultItemToString, titleText, hideLabel, helperText, label, labelKey, type, size, disabled, initialSelectedItems, sortItems, compareItems, clearSelectionText, clearSelectionDescription, light, invalid, invalidText, warn, warnText, useTitleInItem, translateWithId, downshiftProps, open, selectionFeedback, onChange, onMenuChange, direction, selectedItems: selected, readOnly, locale } = _ref; const prefix = usePrefix(); const { isFluid } = useContext(FormContext); const { current: multiSelectInstanceId } = useRef(getInstanceId()); const [highlightedIndex, setHighlightedIndex] = useState(); const [isFocused, setIsFocused] = useState(false); const [inputFocused, setInputFocused] = useState(false); const [isOpen, setIsOpen] = useState(open || false); const [prevOpenProp, setPrevOpenProp] = useState(open); const [topItems, setTopItems] = useState([]); const { selectedItems: controlledSelectedItems, onItemChange, clearSelection } = useSelection({ disabled, initialSelectedItems, onChange, selectedItems: selected }); const { getToggleButtonProps, getLabelProps, getMenuProps, getItemProps, selectedItem } = useSelect({ ...downshiftProps, highlightedIndex, isOpen, itemToString: items => { return Array.isArray(items) && items.map(function (item) { return itemToString(item); }).join(', ') || ''; }, onStateChange, selectedItem: controlledSelectedItems, items }); const toggleButtonProps = getToggleButtonProps({ onFocus: () => { setInputFocused(true); }, onBlur: () => { setInputFocused(false); } }); const mergedRef = mergeRefs(toggleButtonProps.ref, ref); const selectedItems = selectedItem; /** * wrapper function to forward changes to consumer */ const setIsOpenWrapper = open => { setIsOpen(open); if (onMenuChange) { onMenuChange(open); } }; /** * programmatically control this `open` prop */ if (prevOpenProp !== open) { setIsOpenWrapper(open); setPrevOpenProp(open); } const inline = type === 'inline'; const showWarning = !invalid && warn; const wrapperClasses = cx(`${prefix}--multi-select__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 && invalid, [`${prefix}--list-box__wrapper--inline--invalid`]: inline && invalid, [`${prefix}--list-box__wrapper--fluid--invalid`]: isFluid && invalid, [`${prefix}--list-box__wrapper--fluid--focus`]: !isOpen && isFluid && isFocused, [`msk-multiselect--outside`]: outsideItems }); const titleClasses = cx(`${prefix}--label`, { [`${prefix}--label--disabled`]: disabled, [`${prefix}--visually-hidden`]: hideLabel }); const helperId = !helperText ? undefined : `multiselect-helper-text-${multiSelectInstanceId}`; const fieldLabelId = `multiselect-field-label-${multiSelectInstanceId}`; const helperClasses = cx(`${prefix}--form__helper-text`, { [`${prefix}--form__helper-text--disabled`]: disabled }); const className = cx(`${prefix}--multi-select`, { [`${prefix}--multi-select--invalid`]: invalid, [`${prefix}--multi-select--invalid--focused`]: invalid && inputFocused, [`${prefix}--multi-select--warning`]: showWarning, [`${prefix}--multi-select--inline`]: inline, [`${prefix}--multi-select--selected`]: selectedItems && selectedItems.length > 0, [`${prefix}--list-box--up`]: direction === 'top', [`${prefix}--multi-select--readonly`]: readOnly }); // needs to be capitalized for react to render it correctly // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const ItemToElement = itemToElement; const sortOptions = { selectedItems: controlledSelectedItems, itemToString, compareItems, locale }; if (selectionFeedback === 'fixed') { sortOptions.selectedItems = []; } else if (selectionFeedback === 'top-after-reopen') { sortOptions.selectedItems = topItems; } function onStateChange(changes) { if (changes.isOpen && !isOpen) { setTopItems(controlledSelectedItems); } const { type } = changes; switch (type) { case ItemClick: case MenuKeyDownSpaceButton: if (changes.selectedItem === undefined) { break; } onItemChange(changes.selectedItem); break; case MenuKeyDownArrowDown: case MenuKeyDownArrowUp: setHighlightedIndex(changes.highlightedIndex); break; case MenuBlur: case MenuKeyDownEscape: setIsOpenWrapper(false); setHighlightedIndex(changes.highlightedIndex); break; case ToggleButtonClick: setIsOpenWrapper(changes.isOpen || false); setHighlightedIndex(changes.highlightedIndex); break; } } const onKeyDown = e => { if (!disabled) { if (match(e, Delete) || match(e, Escape)) { clearSelection(); e.stopPropagation(); } if (match(e, Space) || match(e, ArrowDown)) { setIsOpenWrapper(true); } } }; const multiSelectFieldWrapperClasses = cx(`${prefix}--list-box__field--wrapper`, { [`${prefix}--list-box__field--wrapper--input-focused`]: inputFocused }); const handleFocus = evt => { evt.target.classList.contains(`${prefix}--tag__close-icon`) ? setIsFocused(false) : setIsFocused(evt.type === 'focus' ? true : false); }; const readOnlyEventHandlers = readOnly ? { onClick: evt => { // NOTE: does not prevent click evt.preventDefault(); // focus on the element as per readonly input behavior if (mergedRef.current !== undefined) { mergedRef.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(); } } } : {}; return /*#__PURE__*/React__default.createElement(React__default.Fragment, null, /*#__PURE__*/React__default.createElement("div", { className: wrapperClasses }, /*#__PURE__*/React__default.createElement("label", _extends({ className: titleClasses }, getLabelProps()), titleText && titleText, selectedItems.length > 0 && /*#__PURE__*/React__default.createElement("span", { className: `${prefix}--visually-hidden` }, clearSelectionDescription, " ", selectedItems.length, ",", clearSelectionText)), /*#__PURE__*/React__default.createElement(ListBox, { onFocus: isFluid ? handleFocus : undefined, onBlur: isFluid ? handleFocus : undefined, type: type, size: size, className: className, disabled: disabled, light: light, invalid: invalid, invalidText: invalidText, warn: warn, warnText: warnText, isOpen: isOpen, id: id }, /*#__PURE__*/React__default.createElement("div", { className: multiSelectFieldWrapperClasses }, selectedItems.length > 0 && (countOnly ? /*#__PURE__*/React__default.createElement(ListBox.Selection, { readOnly: readOnly, clearSelection: !disabled && !readOnly ? clearSelection : noop, selectionCount: selectedItems.length // eslint-disable-next-line @typescript-eslint/no-non-null-assertion , translateWithId: translateWithId, disabled: disabled }) : !outsideItems ? /*#__PURE__*/React__default.createElement(React__default.Fragment, null, selectedItems.map((item, index) => { const clearSelection = () => { onItemChange(item); }; const itemLabel = typeof labelKey === 'string' ? item[labelKey] : item; return /*#__PURE__*/React__default.createElement(ListBox.Selection, { key: index, index: index // @ts-ignore , label: itemLabel, clearSelection: !disabled && !readOnly ? clearSelection : noop // @ts-ignore , translateWithId: translateWithId, disabled: disabled }); }), /*#__PURE__*/React__default.createElement("button", { type: "button", className: "msk-multiselect--clear-all-btn", onClick: clearSelection }, _span || (_span = /*#__PURE__*/React__default.createElement("span", { className: "msk-multiselect--clear-all-btn-text" }, "Clear all")), _span2 || (_span2 = /*#__PURE__*/React__default.createElement("span", { className: "msk-icon msk-multiselect--clear-all-btn-icon" }, "clear")))) : null), /*#__PURE__*/React__default.createElement("button", _extends({ type: "button", className: `${prefix}--list-box__field msk-multiselect--expand-btn ${selectedItems.length > 0 ? 'msk-multiselect--selected' : ''}`.trim(), disabled: disabled, "aria-disabled": disabled || readOnly, "aria-describedby": !inline && !invalid && !warn && helperText ? helperId : undefined }, toggleButtonProps, { ref: mergedRef, onKeyDown: onKeyDown }, readOnlyEventHandlers), outsideItems || selectedItems.length === 0 ? /*#__PURE__*/React__default.createElement("span", { id: fieldLabelId, className: `${prefix}--list-box__label` }, label) : null, /*#__PURE__*/React__default.createElement("div", { className: `msk-multiselect--expand-btn-icon-wrapper ${isOpen ? 'open' : ''}`.trim() }, _span3 || (_span3 = /*#__PURE__*/React__default.createElement("span", { className: `msk-icon msk-multiselect--expand-btn-icon` }, "expand_more"))))), /*#__PURE__*/React__default.createElement(ListBox.Menu, _extends({ "aria-multiselectable": "true" }, getMenuProps()), isOpen && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion sortItems(items, sortOptions).map((item, index) => { const isChecked = selectedItems.filter(selected => isEqual(selected, item)).length > 0; const itemProps = getItemProps({ item, // we don't want Downshift to set aria-selected for us // we also don't want to set 'false' for reader verbosity's sake ['aria-selected']: isChecked ? true : undefined, disabled: item.disabled }); const itemText = itemToString(item); return /*#__PURE__*/React__default.createElement(ListBox.MenuItem, _extends({ key: itemProps.id, isActive: isChecked, "aria-label": itemText, isHighlighted: highlightedIndex === index, title: itemText }, itemProps), /*#__PURE__*/React__default.createElement("div", { className: `${prefix}--checkbox-wrapper` }, /*#__PURE__*/React__default.createElement("span", { title: useTitleInItem ? itemText : undefined, className: `${prefix}--checkbox-label`, "data-contained-checkbox-state": isChecked, id: `${itemProps.id}__checkbox` }, itemToElement ? /*#__PURE__*/React__default.createElement(ItemToElement, _extends({ key: itemProps.id }, item)) : itemText))); })))), !inline && !invalid && !warn && helperText && /*#__PURE__*/React__default.createElement("div", { id: helperId, className: helperClasses }, helperText), outsideItems && selectedItems.length > 0 && /*#__PURE__*/React__default.createElement("div", { className: "msk-multiselect--outside-items-wrapper" }, selectedItems.map((item, index) => { const clearSelection = () => { onItemChange(item); }; const itemLabel = typeof labelKey === 'string' ? item[labelKey] : item; return /*#__PURE__*/React__default.createElement(ListBox.Selection, { key: index, index: index // @ts-ignore , label: itemLabel, clearSelection: !disabled && !readOnly ? clearSelection : noop // @ts-ignore , translateWithId: translateWithId, disabled: disabled }); }), /*#__PURE__*/React__default.createElement("button", { type: "button", className: "msk-multiselect--clear-all-btn-outside", onClick: clearSelection }, _span4 || (_span4 = /*#__PURE__*/React__default.createElement("span", { className: "msk-multiselect--clear-all-btn-text" }, "Clear all")), _span5 || (_span5 = /*#__PURE__*/React__default.createElement("span", { className: "msk-icon msk-multiselect--clear-all-btn-icon" }, "clear"))))); }); MultiSelect.displayName = 'MultiSelect'; MultiSelect.propTypes = { ...sortingPropTypes, /** * Provide a custom class name to be added to the outermost node in the * component */ className: PropTypes.string, /** * Specify the text that should be read for screen readers that describes total items selected */ clearSelectionDescription: PropTypes.string, /** * Specify the text that should be read for screen readers to clear selection. */ clearSelectionText: PropTypes.string, /** * only show the count and not individual selections */ countOnly: PropTypes.bool, /** * Specify the direction of the multiselect dropdown. Can be either top or bottom. */ direction: PropTypes.oneOf(['top', 'bottom']), /** * Disable the control */ disabled: PropTypes.bool, /** * Additional props passed to Downshift */ // @ts-ignore downshiftProps: PropTypes.shape(Downshift.propTypes), /** * 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 arbitrary items from their collection that are * pre-selected */ initialSelectedItems: PropTypes.array, /** * Is the current selection invalid? */ invalid: PropTypes.bool, /** * If invalid, what is the error? */ invalidText: PropTypes.node, /** * Function to render items as custom components instead of strings. * Defaults to null and is overridden by a getter */ 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, /** * Specify the key in the object to use for the label. */ labelKey: PropTypes.string, /** * `true` to use the light version. */ light: deprecate(PropTypes.bool, 'The `light` prop for `MultiSelect` has ' + 'been deprecated in favor of the new `Layer` component. It will be removed in the next major release.'), /** * Specify the locale of the control. Used for the default `compareItems` * used for sorting the list of items in the control. */ locale: PropTypes.string, /** * `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, /** * `onMenuChange` is a utility for this controlled component to communicate to a * consuming component that the menu was open(`true`)/closed(`false`). */ onMenuChange: PropTypes.func, /** * Initialize the component with an open(`true`)/closed(`false`) menu. */ open: PropTypes.bool, /** * put selected items outside of the dropdown */ outsideItems: PropTypes.bool, /** * Whether or not the Dropdown is readonly */ readOnly: PropTypes.bool, /** * For full control of the selected items */ selectedItems: PropTypes.array, /** * Specify feedback (mode) of the selection. * `top`: selected item jumps to top * `fixed`: selected item stays at it's position * `top-after-reopen`: selected item jump to top after reopen dropdown */ selectionFeedback: PropTypes.oneOf(['top', 'fixed', 'top-after-reopen']), /** * Specify the size of the ListBox. Currently supports either `sm`, `md` or `lg` as an option. */ size: ListBoxSize, /** * Provide text to be used in a `<label>` element that is tied to the * multiselect via ARIA attributes. */ titleText: PropTypes.node, /** * Callback function for translating ListBoxMenuIcon SVG title */ translateWithId: PropTypes.func, /** * Specify 'inline' to create an inline multi-select. */ type: PropTypes.oneOf(['default', 'inline']), /** * Specify title to show title on hover */ useTitleInItem: PropTypes.bool, /** * 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 }; export { MultiSelect as default };