diff --git a/packages/eds-core-react/src/components/Autocomplete/Autocomplete.docs.mdx b/packages/eds-core-react/src/components/Autocomplete/Autocomplete.docs.mdx index d44bfb4fbc..f892d9d043 100644 --- a/packages/eds-core-react/src/components/Autocomplete/Autocomplete.docs.mdx +++ b/packages/eds-core-react/src/components/Autocomplete/Autocomplete.docs.mdx @@ -148,8 +148,8 @@ Example using [react-hook-form](https://react-hook-form.com/) library. ### With Select All option -This is an example of how to make a "Select All" option for quicker selection. -Make sure to use `useMemo` if you are mutating the options in the render function to avoid object memory references bugs on re-renders. + +Use prop `allowSelectAll` diff --git a/packages/eds-core-react/src/components/Autocomplete/Autocomplete.stories.tsx b/packages/eds-core-react/src/components/Autocomplete/Autocomplete.stories.tsx index 03ab03f050..caecca2ebb 100644 --- a/packages/eds-core-react/src/components/Autocomplete/Autocomplete.stories.tsx +++ b/packages/eds-core-react/src/components/Autocomplete/Autocomplete.stories.tsx @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-return */ -import { useState, useEffect, useMemo, useRef } from 'react' +import { useState, useEffect, useRef } from 'react' import styled from 'styled-components' import { Autocomplete, AutocompleteProps, AutocompleteChanges } from '.' import { Checkbox } from '../Checkbox' @@ -854,57 +854,28 @@ export const WithReactHookForm: StoryFn< export const SelectAll: StoryFn> = (args) => { const { options } = args - - const selectAllOption: MyOptionType = useMemo( - () => ({ - label: 'Select All', - }), - [], - ) - - const optionsWithAll = useMemo( - () => [selectAllOption, ...options], - [options, selectAllOption], - ) - const [selectedItems, setSelectedItems] = useState([]) - const onChange = (changes: AutocompleteChanges) => { - const nextAll = changes.selectedItems.find( - (item) => item.label === selectAllOption.label, - ) - const prevAll = selectedItems.find( - (item) => item.label === selectAllOption.label, - ) - - switch (true) { - case nextAll && selectedItems.length === 1: - case prevAll && !nextAll: - setSelectedItems([]) - break - case !prevAll && changes.selectedItems.length === options.length: - case nextAll && !prevAll: - setSelectedItems(optionsWithAll) - break - case nextAll && - changes.selectedItems.length === optionsWithAll.length - 1: - setSelectedItems( - changes.selectedItems.filter( - (option) => !(option.label === selectAllOption.label), - ), - ) - break - default: - setSelectedItems(changes.selectedItems) - break - } - } - const onDelete = (itemLabel: string) => setSelectedItems(selectedItems.filter((x) => !(x.label === itemLabel))) + const onChange = (e: AutocompleteChanges) => { + action('optionsChange')(e) + setSelectedItems(e.selectedItems) + } + return ( <> +
> = (args) => { gridTemplateColumns: 'repeat(4, auto)', }} > - {selectedItems - .filter((option) => !(option.label === selectAllOption.label)) - .map((x) => ( - onDelete(x.label)}> - {x.label} - - ))} + {selectedItems.map((x) => ( + onDelete(x.label)}> + {x.label} + + ))}
- !(o.label === selectAllOption.label)) - .length - }/ ${options.length} selected`} - multiple - optionLabel={optionLabel} - /> ) } diff --git a/packages/eds-core-react/src/components/Autocomplete/Autocomplete.test.tsx b/packages/eds-core-react/src/components/Autocomplete/Autocomplete.test.tsx index 826acf1cf4..65ecf494b7 100644 --- a/packages/eds-core-react/src/components/Autocomplete/Autocomplete.test.tsx +++ b/packages/eds-core-react/src/components/Autocomplete/Autocomplete.test.tsx @@ -181,6 +181,43 @@ describe('Autocomplete', () => { expect(checked.length).toBe(2) }) + it('Can select all options', async () => { + const onChange = jest.fn() + render( + , + ) + + const labeledNodes = await screen.findAllByLabelText(labelText) + const optionsList = labeledNodes[1] + + const buttonNode = await screen.findByLabelText('toggle options', { + selector: 'button', + }) + + fireEvent.click(buttonNode) + + const options = await within(optionsList).findAllByRole('option') + fireEvent.click(options[0]) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith({ selectedItems: items }) + }) + + fireEvent.click(options[0]) + + await waitFor(() => { + expect(onChange).toHaveBeenCalledWith({ selectedItems: [] }) + }) + }) + it('Can open the options on button click', async () => { render() diff --git a/packages/eds-core-react/src/components/Autocomplete/Autocomplete.tsx b/packages/eds-core-react/src/components/Autocomplete/Autocomplete.tsx index ca943d5668..32a74c7079 100644 --- a/packages/eds-core-react/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/eds-core-react/src/components/Autocomplete/Autocomplete.tsx @@ -58,6 +58,8 @@ const Container = styled.div` position: relative; ` +const AllSymbol = Symbol('Select all') + const StyledList = styled(List)( ({ theme }) => css` background-color: ${theme.background}; @@ -96,7 +98,10 @@ const StyledButton = styled(Button)( `, ) -type IndexFinderType = ({ +// 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 +type IndexFinderType = ({ calc, index, optionDisabled, @@ -244,6 +249,8 @@ export type AutocompleteProps = { onInputChange?: (text: string) => void /** Enable multiselect */ multiple?: boolean + /** Add select-all option. Throws an error if true while multiple = false */ + allowSelectAll?: boolean /** Custom option label. NOTE: This is required when option is an object */ optionLabel?: (option: T) => string /** Custom option template */ @@ -292,6 +299,7 @@ function AutocompleteInner( onInputChange, selectedOptions, multiple, + allowSelectAll, initialSelectedOptions = [], disablePortal, optionDisabled = () => false, @@ -312,9 +320,29 @@ function AutocompleteInner( const isControlled = Boolean(selectedOptions) const [inputOptions, setInputOptions] = useState(options) - const [availableItems, setAvailableItems] = useState(inputOptions) + const [_availableItems, setAvailableItems] = useState(inputOptions) const [typedInputValue, setTypedInputValue] = useState('') + 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 as T, ..._availableItems] + return _availableItems + }, [_availableItems, showSelectAll]) + + const toggleAllSelected = () => { + if (selectedItems.length === inputOptions.length) { + setSelectedItems([]) + } else { + setSelectedItems(inputOptions) + } + } + useEffect(() => { const availableHash = JSON.stringify(inputOptions) const optionsHash = JSON.stringify(options) @@ -353,7 +381,9 @@ function AutocompleteInner( ...multipleSelectionProps, onSelectedItemsChange: (changes) => { if (onOptionsChange) { - const { selectedItems } = changes + const selectedItems = changes.selectedItems.filter( + (item) => item !== AllSymbol, + ) onOptionsChange({ selectedItems }) } }, @@ -376,6 +406,14 @@ function AutocompleteInner( setSelectedItems, } = useMultipleSelection(multipleSelectionProps) + const allSelectedState = useMemo(() => { + if (!inputOptions || !selectedItems) return 'NONE' + if (inputOptions.length === selectedItems.length) return 'ALL' + if (inputOptions.length != selectedItems.length && selectedItems.length > 0) + return 'SOME' + return 'NONE' + }, [inputOptions, selectedItems]) + const getLabel = useCallback( (item: T) => { //note: non strict check for null or undefined to allow 0 @@ -448,7 +486,9 @@ function AutocompleteInner( type !== useCombobox.stateChangeTypes.MenuMouseLeave && highlightedIndex >= 0 ) { - rowVirtualizer.scrollToIndex(highlightedIndex) + rowVirtualizer.scrollToIndex(highlightedIndex, { + align: allowSelectAll ? 'center' : 'auto', + }) } }, onIsOpenChange: ({ selectedItem }) => { @@ -465,7 +505,9 @@ function AutocompleteInner( case useCombobox.stateChangeTypes.ItemClick: //note: non strict check for null or undefined to allow 0 if (selectedItem != null && !optionDisabled(selectedItem)) { - if (multiple) { + if (selectedItem === AllSymbol) { + toggleAllSelected() + } else if (multiple) { selectedItems.includes(selectedItem) ? removeSelectedItem(selectedItem) : addSelectedItem(selectedItem) @@ -606,6 +648,9 @@ function AutocompleteInner( } case useCombobox.stateChangeTypes.InputKeyDownEnter: case useCombobox.stateChangeTypes.ItemClick: + if (clearSearchOnChange) { + setTypedInputValue('') + } return { ...changes, isOpen: true, // keep menu open after selection. @@ -756,10 +801,44 @@ function AutocompleteInner( const label = getLabel(item) const isDisabled = optionDisabled(item) const isSelected = selectedItemsLabels.includes(label) + if (item === AllSymbol) { + return ( + + ) + } return ( function AutocompleteOptionInner( @@ -73,6 +74,7 @@ function AutocompleteOptionInner( optionComponent, multiple, isSelected, + indeterminate, isDisabled, multiline, highlighted, @@ -97,6 +99,7 @@ function AutocompleteOptionInner( { return null