UNPKG

@equinor/eds-core-react

Version:

The React implementation of the Equinor Design System

796 lines (779 loc) 26.2 kB
import { forwardRef, useState, useRef, useMemo, useEffect, useCallback } from 'react'; import { useMultipleSelection, useCombobox } from 'downshift'; import { useVirtualizer } from '@tanstack/react-virtual'; import styled, { css, ThemeProvider } from 'styled-components'; import { Button } from '../Button/index.js'; import { List } from '../List/index.js'; import { Icon } from '../Icon/index.js'; import { Progress } from '../Progress/index.js'; import { close, arrow_drop_up, arrow_drop_down } from '@equinor/eds-icons'; import { multiSelect, selectTokens } from './Autocomplete.tokens.js'; import { bordersTemplate, useToken, useIsomorphicLayoutEffect } from '@equinor/eds-utils'; import { AutocompleteOption } from './Option.js'; import { useFloating, offset, flip, size, useInteractions, autoUpdate } from '@floating-ui/react'; import { jsx, jsxs, Fragment } from 'react/jsx-runtime'; import pickBy from '../../node_modules/.pnpm/ramda@0.30.1/node_modules/ramda/es/pickBy.js'; import mergeWith from '../../node_modules/.pnpm/ramda@0.30.1/node_modules/ramda/es/mergeWith.js'; import { HelperText as TextfieldHelperText } from '../InputWrapper/HelperText/HelperText.js'; import { useEds } from '../EdsProvider/eds.context.js'; import { Label } from '../Label/Label.js'; import { Input } from '../Input/Input.js'; const Container = styled.div.withConfig({ displayName: "Autocomplete__Container", componentId: "sc-yvif0e-0" })(["position:relative;"]); const AllSymbol = Symbol('Select all'); // MARK: styled components const StyledList = styled(List).withConfig({ displayName: "Autocomplete__StyledList", componentId: "sc-yvif0e-1" })(({ theme }) => css(["background-color:", ";box-shadow:", ";", " overflow-y:auto;overflow-x:hidden;padding:0;display:grid;@supports (-moz-appearance:none){scrollbar-width:thin;}"], theme.background, theme.boxShadow, bordersTemplate(theme.border))); const StyledPopover = styled('div').withConfig({ shouldForwardProp: () => true //workaround to avoid warning until popover gets added to react types }).withConfig({ displayName: "Autocomplete__StyledPopover", componentId: "sc-yvif0e-2" })(["inset:unset;border:0;padding:0;margin:0;overflow:visible;&::backdrop{background-color:transparent;}"]); const HelperText = styled(TextfieldHelperText).withConfig({ displayName: "Autocomplete__HelperText", componentId: "sc-yvif0e-3" })(["margin-top:8px;margin-left:8px;"]); const AutocompleteNoOptions = styled(AutocompleteOption).withConfig({ displayName: "Autocomplete__AutocompleteNoOptions", componentId: "sc-yvif0e-4" })(({ theme }) => css(["color:", ";"], theme.entities.noOptions.typography.color)); const StyledButton = styled(Button).withConfig({ displayName: "Autocomplete__StyledButton", componentId: "sc-yvif0e-5" })(({ theme: { entities: { button } } }) => css(["height:", ";width:", ";"], button.height, button.height)); // MARK: outside functions // Typescript can struggle with parsing generic arrow functions in a .tsx file (see https://github.com/microsoft/TypeScript/issues/15713) // Workaround is to add a trailing , after T, which tricks the compiler, but also have to ignore prettier rule. // prettier-ignore const findIndex = ({ calc, index, optionDisabled, availableItems }) => { const nextItem = availableItems[index]; if (optionDisabled(nextItem) && index >= 0 && index < availableItems.length) { const nextIndex = calc(index); return findIndex({ calc, index: nextIndex, availableItems, optionDisabled }); } return index; }; const isEvent = (val, key) => /^on[A-Z](.*)/.test(key) && typeof val === 'function'; function mergeEventsFromRight(props1, props2) { const events1 = pickBy(isEvent, props1); const events2 = pickBy(isEvent, props2); return mergeWith((event1, event2) => { return (...args) => { event1(...args); event2(...args); }; }, events1, events2); } const findNextIndex = ({ index, optionDisabled, availableItems, allDisabled }) => { if (allDisabled) return 0; const options = { index, optionDisabled, availableItems, calc: num => num + 1 }; const nextIndex = findIndex(options); if (nextIndex > availableItems.length - 1) { // jump to start of list return findIndex({ ...options, index: 0 }); } return nextIndex; }; const findPrevIndex = ({ index, optionDisabled, availableItems, allDisabled }) => { if (allDisabled) return 0; const options = { index, optionDisabled, availableItems, calc: num => num - 1 }; const prevIndex = findIndex(options); if (prevIndex < 0) { // jump to end of list return findIndex({ ...options, index: availableItems.length - 1 }); } return prevIndex; }; /*When a user clicks the StyledList scrollbar, the input looses focus which breaks downshift * keyboard navigation in the list. This code returns focus to the input on mouseUp */ const handleListFocus = e => { e.preventDefault(); e.stopPropagation(); window?.addEventListener('mouseup', () => { e.relatedTarget?.focus(); }, { once: true }); }; const defaultOptionDisabled = () => false; // MARK: types // MARK: component function AutocompleteInner(props, ref) { const { options = [], label, meta, className, style, disabled = false, readOnly = false, loading = false, hideClearButton = false, onOptionsChange, onInputChange, selectedOptions: _selectedOptions, multiple, itemCompare, allowSelectAll, initialSelectedOptions: _initialSelectedOptions = [], optionDisabled = defaultOptionDisabled, optionsFilter, autoWidth, placeholder, optionLabel, clearSearchOnChange = true, multiline = false, dropdownHeight = 300, optionComponent, helperText, helperIcon, noOptionsText = 'No options', variant, onClear, ...other } = props; // MARK: initializing data/setup const selectedOptions = _selectedOptions ? itemCompare ? options.filter(item => _selectedOptions.some(compare => itemCompare(item, compare))) : _selectedOptions : undefined; const initialSelectedOptions = _initialSelectedOptions ? itemCompare ? options.filter(item => _initialSelectedOptions.some(compare => itemCompare(item, compare))) : _initialSelectedOptions : undefined; const isControlled = Boolean(selectedOptions); const [inputOptions, setInputOptions] = useState(options); const [_availableItems, setAvailableItems] = useState(inputOptions); const [typedInputValue, setTypedInputValue] = useState(''); const inputRef = useRef(null); const showSelectAll = useMemo(() => { if (!multiple && allowSelectAll) { throw new Error(`allowSelectAll can only be used with multiple`); } return allowSelectAll && !typedInputValue; }, [allowSelectAll, multiple, typedInputValue]); const availableItems = useMemo(() => { if (showSelectAll) return [AllSymbol, ..._availableItems]; return _availableItems; }, [_availableItems, showSelectAll]); //issue 2304, update dataset when options are added dynamically useEffect(() => { const availableHash = JSON.stringify(inputOptions); const optionsHash = JSON.stringify(options); if (availableHash !== optionsHash) { setInputOptions(options); } }, [options, inputOptions]); useEffect(() => { setAvailableItems(inputOptions); }, [inputOptions]); const { density } = useEds(); const token = useToken({ density }, multiple ? multiSelect : selectTokens); const tokens = token(); let placeholderText = placeholder; let multipleSelectionProps = { initialSelectedItems: multiple ? initialSelectedOptions : initialSelectedOptions[0] ? [initialSelectedOptions[0]] : [] }; if (multiple) { multipleSelectionProps = { ...multipleSelectionProps, onSelectedItemsChange: changes => { if (onOptionsChange) { let selectedItems = changes.selectedItems.filter(item => item !== AllSymbol); if (itemCompare) { selectedItems = inputOptions.filter(item => selectedItems.some(compare => itemCompare(item, compare))); } onOptionsChange({ selectedItems }); } } }; if (isControlled) { multipleSelectionProps = { ...multipleSelectionProps, selectedItems: selectedOptions }; } } const { getDropdownProps, addSelectedItem, removeSelectedItem, selectedItems, setSelectedItems } = useMultipleSelection(multipleSelectionProps); // MARK: select all logic const enabledItems = useMemo(() => { const disabledItemsSet = new Set(inputOptions.filter(optionDisabled)); return inputOptions.filter(x => !disabledItemsSet.has(x)); }, [inputOptions, optionDisabled]); const allDisabled = enabledItems.length === 0; const selectedDisabledItemsSet = useMemo(() => new Set(selectedItems.filter(x => x !== null && optionDisabled(x))), [selectedItems, optionDisabled]); const selectedEnabledItems = useMemo(() => selectedItems.filter(x => !selectedDisabledItemsSet.has(x)), [selectedItems, selectedDisabledItemsSet]); const allSelectedState = useMemo(() => { if (!enabledItems || !selectedEnabledItems) return 'NONE'; if (enabledItems.length === selectedEnabledItems.length) return 'ALL'; if (enabledItems.length != selectedEnabledItems.length && selectedEnabledItems.length > 0) return 'SOME'; return 'NONE'; }, [enabledItems, selectedEnabledItems]); const toggleAllSelected = () => { if (selectedEnabledItems.length === enabledItems.length) { setSelectedItems([...selectedDisabledItemsSet]); } else { setSelectedItems([...enabledItems, ...selectedDisabledItemsSet]); } }; // MARK: getLabel const getLabel = useCallback(item => { //note: non strict check for null or undefined to allow 0 if (item == null) { return ''; } if (typeof item === 'object') { if (optionLabel) { return optionLabel(item); } else { throw new Error('Missing label. When using objects for options make sure to define the `optionLabel` property'); } } if (typeof item === 'string') { return item; } try { // eslint-disable-next-line @typescript-eslint/no-base-to-string return item?.toString(); } catch { throw new Error('Unable to find label, make sure your are using options as documented'); } }, [optionLabel]); // MARK: setup virtualizer const scrollContainer = useRef(null); const rowVirtualizer = useVirtualizer({ count: availableItems.length, getScrollElement: () => scrollContainer.current, estimateSize: useCallback(() => { return parseInt(token().entities.label.minHeight); }, [token]), overscan: 25 }); //https://github.com/TanStack/virtual/discussions/379#discussioncomment-3501037 useIsomorphicLayoutEffect(() => { rowVirtualizer?.measure?.(); }, [rowVirtualizer, density]); // MARK: downshift state let comboBoxProps = { items: availableItems, //can not pass readonly type to downshift so we cast it to regular T[] initialSelectedItem: initialSelectedOptions[0], isItemDisabled(item) { return optionDisabled(item); }, itemToString: getLabel, onInputValueChange: ({ inputValue }) => { onInputChange && onInputChange(inputValue); setAvailableItems(options.filter(item => { if (optionsFilter) { return optionsFilter(item, inputValue); } return getLabel(item).toLowerCase().includes(inputValue.toLowerCase()); })); }, onHighlightedIndexChange({ highlightedIndex, type }) { if (type == useCombobox.stateChangeTypes.InputClick || type == useCombobox.stateChangeTypes.InputKeyDownArrowDown && !isOpen || type == useCombobox.stateChangeTypes.InputKeyDownArrowUp && !isOpen) { //needs delay for dropdown to render before calling scroll setTimeout(() => { rowVirtualizer.scrollToIndex(highlightedIndex, { align: allowSelectAll ? 'center' : 'auto' }); }, 1); } else if (type !== useCombobox.stateChangeTypes.ItemMouseMove && type !== useCombobox.stateChangeTypes.MenuMouseLeave && highlightedIndex >= 0) { rowVirtualizer.scrollToIndex(highlightedIndex, { align: allowSelectAll ? 'center' : 'auto' }); } }, onIsOpenChange: ({ selectedItem }) => { if (!multiple && selectedItem !== null) { setAvailableItems(options); } }, onStateChange: ({ type, selectedItem }) => { switch (type) { case useCombobox.stateChangeTypes.InputChange: case useCombobox.stateChangeTypes.InputBlur: break; case useCombobox.stateChangeTypes.InputKeyDownEnter: case useCombobox.stateChangeTypes.ItemClick: //note: non strict check for null or undefined to allow 0 if (selectedItem != null && !optionDisabled(selectedItem)) { if (selectedItem === AllSymbol) { toggleAllSelected(); } else if (multiple) { const shouldRemove = itemCompare ? selectedItems.some(i => itemCompare(selectedItem, i)) : selectedItems.includes(selectedItem); if (shouldRemove) { removeSelectedItem(selectedItem); } else { addSelectedItem(selectedItem); } } else { setSelectedItems([selectedItem]); } } break; } } }; // MARK: singleselect specific if (!multiple) { comboBoxProps = { ...comboBoxProps, onSelectedItemChange: changes => { if (onOptionsChange) { let { selectedItem } = changes; if (itemCompare) { selectedItem = inputOptions.find(item => itemCompare(item, selectedItem)); } onOptionsChange({ selectedItems: selectedItem ? [selectedItem] : [] }); } }, stateReducer: (_, actionAndChanges) => { const { changes, type } = actionAndChanges; switch (type) { case useCombobox.stateChangeTypes.InputClick: return { ...changes, isOpen: !(disabled || readOnly) }; case useCombobox.stateChangeTypes.InputBlur: return { ...changes, inputValue: changes.selectedItem ? getLabel(changes.selectedItem) : '' }; case useCombobox.stateChangeTypes.InputKeyDownArrowDown: case useCombobox.stateChangeTypes.InputKeyDownHome: if (readOnly) { return { ...changes, isOpen: false }; } return { ...changes, highlightedIndex: findNextIndex({ index: changes.highlightedIndex, availableItems, optionDisabled, allDisabled }) }; case useCombobox.stateChangeTypes.InputKeyDownArrowUp: case useCombobox.stateChangeTypes.InputKeyDownEnd: if (readOnly) { return { ...changes, isOpen: false }; } return { ...changes, highlightedIndex: findPrevIndex({ index: changes.highlightedIndex, availableItems, optionDisabled, allDisabled }) }; case useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem: setSelectedItems([changes.selectedItem]); return { ...changes }; default: return changes; } } }; if (isControlled) { comboBoxProps = { ...comboBoxProps, selectedItem: selectedOptions[0] || null }; } } // MARK: multiselect specific if (multiple) { placeholderText = typeof placeholderText !== 'undefined' ? placeholderText : `${selectedItems.length}/${inputOptions.length} selected`; comboBoxProps = { ...comboBoxProps, selectedItem: null, stateReducer: (state, actionAndChanges) => { const { changes, type } = actionAndChanges; switch (type) { case useCombobox.stateChangeTypes.InputClick: return { ...changes, isOpen: !(disabled || readOnly) }; case useCombobox.stateChangeTypes.InputKeyDownArrowDown: case useCombobox.stateChangeTypes.InputKeyDownHome: if (readOnly) { return { ...changes, isOpen: false }; } return { ...changes, highlightedIndex: findNextIndex({ index: changes.highlightedIndex, availableItems, optionDisabled, allDisabled }) }; case useCombobox.stateChangeTypes.InputKeyDownArrowUp: case useCombobox.stateChangeTypes.InputKeyDownEnd: if (readOnly) { return { ...changes, isOpen: false }; } return { ...changes, highlightedIndex: findPrevIndex({ index: changes.highlightedIndex, availableItems, optionDisabled, allDisabled }) }; case useCombobox.stateChangeTypes.InputKeyDownEnter: case useCombobox.stateChangeTypes.ItemClick: if (clearSearchOnChange) { setTypedInputValue(''); } return { ...changes, isOpen: true, // keep menu open after selection. highlightedIndex: state.highlightedIndex, inputValue: !clearSearchOnChange ? typedInputValue : '' }; case useCombobox.stateChangeTypes.InputChange: setTypedInputValue(changes.inputValue); return { ...changes }; case useCombobox.stateChangeTypes.InputBlur: setTypedInputValue(''); return { ...changes, inputValue: '' }; case useCombobox.stateChangeTypes.ControlledPropUpdatedSelectedItem: return { ...changes, inputValue: !clearSearchOnChange ? typedInputValue : changes.inputValue }; default: return changes; } } }; } const { isOpen, getToggleButtonProps, getLabelProps, getMenuProps, getInputProps, highlightedIndex, getItemProps, inputValue, reset: resetCombobox } = useCombobox(comboBoxProps); // MARK: floating-ui setup const { x, y, refs, update, strategy } = useFloating({ placement: 'bottom-start', middleware: [offset(4), flip({ boundary: typeof document === 'undefined' ? undefined : document?.body }), size({ apply({ rects, elements }) { const anchorWidth = `${rects.reference.width}px`; Object.assign(elements.floating.style, { width: `${autoWidth ? anchorWidth : 'auto'}` }); }, padding: 10 })] }); const { getFloatingProps } = useInteractions([]); useEffect(() => { if (refs.reference.current && refs.floating.current && isOpen) { return autoUpdate(refs.reference.current, refs.floating.current, update); } }, [refs.reference, refs.floating, update, isOpen]); // MARK: popover toggle useIsomorphicLayoutEffect(() => { if (isOpen) { refs.floating.current?.showPopover(); } else { refs.floating.current?.hidePopover(); } }, [isOpen, refs.floating]); const clear = () => { if (onClear) onClear(); resetCombobox(); //dont clear items if they are selected and disabled setSelectedItems([...selectedDisabledItemsSet]); setTypedInputValue(''); inputRef.current?.focus(); }; const showClearButton = (selectedItems.length > 0 || inputValue) && !readOnly && !hideClearButton; const showNoOptions = isOpen && !availableItems.length && noOptionsText.length > 0; const selectedItemsLabels = useMemo(() => selectedItems.map(getLabel), [selectedItems, getLabel]); // MARK: optionsList const optionsList = /*#__PURE__*/jsx(StyledPopover, { popover: "manual", ...getFloatingProps({ ref: refs.setFloating, onFocus: handleListFocus, style: { position: strategy, top: y || 0, left: x || 0 } }), children: /*#__PURE__*/jsxs(StyledList, { ...getMenuProps({ 'aria-multiselectable': multiple ? 'true' : null, ref: scrollContainer, style: { maxHeight: `${dropdownHeight}px` } }, { suppressRefError: true }), children: [showNoOptions && /*#__PURE__*/jsx(AutocompleteNoOptions, { value: noOptionsText, multiple: false, multiline: false, highlighted: 'false', isSelected: false, isDisabled: true }), isOpen && /*#__PURE__*/jsx("li", { role: "presentation", style: { height: `${rowVirtualizer.getTotalSize()}px`, margin: '0', gridArea: '1 / -1' } }, "total-size"), !isOpen ? null : rowVirtualizer.getVirtualItems().map(virtualItem => { const index = virtualItem.index; const item = availableItems[index]; const label = getLabel(item); const isDisabled = optionDisabled(item); const isSelected = selectedItemsLabels.includes(label); if (item === AllSymbol) { return /*#__PURE__*/jsx(AutocompleteOption, { "data-index": 0, "data-testid": 'select-all', value: 'Select all', "aria-setsize": availableItems.length, multiple: true, isSelected: allSelectedState === 'ALL', indeterminate: allSelectedState === 'SOME', highlighted: highlightedIndex === index && !isDisabled ? 'true' : 'false', isDisabled: false, multiline: multiline, onClick: toggleAllSelected, style: { position: 'sticky', top: 0, zIndex: 99 }, ...getItemProps({ ...(multiline && { ref: rowVirtualizer.measureElement }), item, index: index }) }, 'select-all'); } return /*#__PURE__*/jsx(AutocompleteOption, { "data-index": index, "aria-setsize": availableItems.length, "aria-posinset": index + 1, value: label, multiple: multiple, highlighted: highlightedIndex === index && !isDisabled ? 'true' : 'false', isSelected: isSelected, isDisabled: isDisabled, multiline: multiline, optionComponent: optionComponent?.(item, isSelected), ...getItemProps({ ...(multiline && { ref: rowVirtualizer.measureElement }), item, index, style: { transform: `translateY(${virtualItem.start}px)`, ...(!multiline && { height: `${virtualItem.size}px` }) } }) }, virtualItem.key); })] }) }); const inputProps = getInputProps(getDropdownProps({ preventKeyAction: multiple ? isOpen : undefined, disabled, ref: inputRef })); const consolidatedEvents = mergeEventsFromRight(other, inputProps); // MARK: input return /*#__PURE__*/jsx(ThemeProvider, { theme: token, children: /*#__PURE__*/jsxs(Container, { className: className, style: style, ref: ref, children: [/*#__PURE__*/jsx(Label, { ...getLabelProps(), label: label, meta: meta, disabled: disabled }), /*#__PURE__*/jsx(Container, { ref: refs.setReference, children: /*#__PURE__*/jsx(Input, { ...inputProps, variant: variant, placeholder: placeholderText, readOnly: readOnly, rightAdornmentsWidth: hideClearButton ? 24 + 8 : 24 * 2 + 8, rightAdornments: /*#__PURE__*/jsxs(Fragment, { children: [loading && /*#__PURE__*/jsx(Progress.Circular, { size: 16 }), showClearButton && /*#__PURE__*/jsx(StyledButton, { variant: "ghost_icon", disabled: disabled || readOnly, "aria-label": 'clear options', title: "clear", onClick: clear, children: /*#__PURE__*/jsx(Icon, { data: close, size: 16 }) }), !readOnly && /*#__PURE__*/jsx(StyledButton, { variant: "ghost_icon", ...getToggleButtonProps({ disabled: disabled || readOnly }), "aria-label": 'toggle options', title: "open", children: /*#__PURE__*/jsx(Icon, { data: isOpen ? arrow_drop_up : arrow_drop_down }) })] }), ...other, ...consolidatedEvents }) }), helperText && /*#__PURE__*/jsx(HelperText, { color: variant ? tokens.variants[variant].typography.color : undefined, text: helperText, icon: helperIcon }), optionsList] }) }); } // MARK: exported component const Autocomplete = /*#__PURE__*/forwardRef(AutocompleteInner); export { Autocomplete };