@equinor/eds-core-react
Version:
The React implementation of the Equinor Design System
796 lines (779 loc) • 26.2 kB
JavaScript
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 };