From b2a12f0b5cd8be4cf8aa4752c8e6f4ee210256e5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 2 Jul 2024 16:06:34 +0200 Subject: [PATCH] Allow multiple selection in SelectNext --- src/components/input/SelectContext.ts | 4 +- src/components/input/SelectNext.tsx | 131 ++++++++++++------ src/components/input/test/SelectNext-test.js | 96 ++++++++++++- .../patterns/prototype/SelectNextPage.tsx | 25 ++++ .../examples/select-next-multiple.tsx | 49 +++++++ 5 files changed, 259 insertions(+), 46 deletions(-) create mode 100644 src/pattern-library/examples/select-next-multiple.tsx diff --git a/src/components/input/SelectContext.ts b/src/components/input/SelectContext.ts index 5d5cafc9f..9c3ab4661 100644 --- a/src/components/input/SelectContext.ts +++ b/src/components/input/SelectContext.ts @@ -1,8 +1,8 @@ import { createContext } from 'preact'; export type SelectContextType = { - selectValue: (newValue: T) => void; - value: T; + selectValue: (newValue: T | T[]) => void; + value: T | T[]; }; const SelectContext = createContext(null); diff --git a/src/components/input/SelectNext.tsx b/src/components/input/SelectNext.tsx index 1fd197712..d159d93c1 100644 --- a/src/components/input/SelectNext.tsx +++ b/src/components/input/SelectNext.tsx @@ -60,7 +60,35 @@ function SelectOption({ } const { selectValue, value: currentValue } = selectContext; - const selected = !disabled && currentValue === value; + const valueIsArray = Array.isArray(currentValue); + + const selected = + !disabled && + ((valueIsArray && currentValue.includes(value)) || currentValue === value); + + const selectOrToggle = useCallback(() => { + // In single-select, just set current value + if (!valueIsArray) { + selectValue(value); + return; + } + + // In multi-select, clear selection for nullish values + if (!value) { + selectValue([]); + return; + } + + // In multi-select, toggle clicked items + const index = currentValue.indexOf(value); + if (index === -1) { + selectValue([...currentValue, value]); + } else { + const copy = [...currentValue]; + copy.splice(index, 1); + selectValue(copy); + } + }, [currentValue, selectValue, value, valueIsArray]); return (
  • ({ )} onClick={() => { if (!disabled) { - selectValue(value); + selectOrToggle(); } }} onKeyPress={e => { if (!disabled && ['Enter', 'Space'].includes(e.code)) { e.preventDefault(); - selectValue(value); + selectOrToggle(); } }} role="option" @@ -215,43 +243,59 @@ function useListboxPositioning( }, [adjustListboxPositioning, asPopover]); } -export type SelectProps = CompositeProps & { +type SingleValueProps = { value: T; onChange: (newValue: T) => void; - buttonContent?: ComponentChildren; - disabled?: boolean; +}; - /** - * `id` attribute for the toggle button. This is useful to associate a label - * with the control. - */ - buttonId?: string; - - /** Additional classes to pass to container */ - containerClasses?: string | string[]; - /** Additional classes to pass to toggle button */ - buttonClasses?: string | string[]; - /** Additional classes to pass to listbox */ - listboxClasses?: string | string[]; - - /** - * Align the listbox to the right. - * Useful when the listbox is bigger than the toggle button and this component - * is rendered next to the right side of the page/container. - * Defaults to false. - */ - right?: boolean; - - 'aria-label'?: string; - 'aria-labelledby'?: string; - - /** - * Used to determine if the listbox should use the popover API. - * Defaults to true, as long as the browser supports it. - */ - listboxAsPopover?: boolean; +type MultiValueProps = { + value: T[]; + onChange: (newValue: T[]) => void; }; +export type SelectProps = CompositeProps & + (SingleValueProps | MultiValueProps) & { + buttonContent?: ComponentChildren; + disabled?: boolean; + + /** + * Whether this select should allow multi-selection or not. + * When this is true, the listbox is kept open when an option is selected. + * Defaults to false. + */ + multiple?: boolean; + + /** + * `id` attribute for the toggle button. This is useful to associate a label + * with the control. + */ + buttonId?: string; + + /** Additional classes to pass to container */ + containerClasses?: string | string[]; + /** Additional classes to pass to toggle button */ + buttonClasses?: string | string[]; + /** Additional classes to pass to listbox */ + listboxClasses?: string | string[]; + + /** + * Align the listbox to the right. + * Useful when the listbox is bigger than the toggle button and this component + * is rendered next to the right side of the page/container. + * Defaults to false. + */ + right?: boolean; + + 'aria-label'?: string; + 'aria-labelledby'?: string; + + /** + * Used to determine if the listbox should use the popover API. + * Defaults to true, as long as the browser supports it. + */ + listboxAsPopover?: boolean; + }; + function SelectMain({ buttonContent, value, @@ -264,11 +308,16 @@ function SelectMain({ listboxClasses, containerClasses, right = false, + multiple = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, /* eslint-disable-next-line no-prototype-builtins */ listboxAsPopover = HTMLElement.prototype.hasOwnProperty('popover'), }: SelectProps) { + if (multiple && !Array.isArray(value)) { + throw new Error('When `multiple` is true, the value must be an array'); + } + const wrapperRef = useRef(null); const listboxRef = useRef(null); const [listboxOpen, setListboxOpen] = useState(false); @@ -295,11 +344,14 @@ function SelectMain({ ); const selectValue = useCallback( - (newValue: unknown) => { - onChange(newValue as T); - closeListbox(); + (value: unknown) => { + onChange(value as any); + // In multi-select mode, keep list open when selecting values + if (!multiple) { + closeListbox(); + } }, - [closeListbox, onChange], + [onChange, multiple, closeListbox], ); // When clicking away, focusing away or pressing `Esc`, close the listbox @@ -387,6 +439,7 @@ function SelectMain({ role="listbox" ref={listboxRef} id={listboxId} + aria-multiselectable={multiple} aria-labelledby={buttonId ?? defaultButtonId} aria-orientation="vertical" data-testid="select-listbox" diff --git a/src/components/input/test/SelectNext-test.js b/src/components/input/test/SelectNext-test.js index c2488422f..80f449b10 100644 --- a/src/components/input/test/SelectNext-test.js +++ b/src/components/input/test/SelectNext-test.js @@ -30,6 +30,9 @@ describe('SelectNext', () => { const wrapper = mount( + + Reset + {items.map(item => ( { return listboxTop < buttonTop; }; + const clickOption = (wrapper, id) => + wrapper.find(`[data-testid="option-${id}"]`).simulate('click'); + it('changes selected value when an option is clicked', () => { const onChange = sinon.stub(); const wrapper = createComponent({ onChange }); - const clickOption = index => - wrapper.find(`[data-testid="option-${index}"]`).simulate('click'); - clickOption(3); + clickOption(wrapper, 3); assert.calledWith(onChange.lastCall, items[2]); - clickOption(5); + clickOption(wrapper, 5); assert.calledWith(onChange.lastCall, items[4]); - clickOption(1); + clickOption(wrapper, 1); assert.calledWith(onChange.lastCall, items[0]); }); @@ -385,6 +389,71 @@ describe('SelectNext', () => { }); }); + context('when multi-selection is enabled', () => { + it('throws if multiple is true and the value is not an arry', async () => { + assert.throws( + () => createComponent({ multiple: true }), + 'When `multiple` is true, the value must be an array', + ); + }); + + it('keeps listbox open when an option is selected if multiple is true', async () => { + const wrapper = createComponent({ multiple: true, value: [] }); + + toggleListbox(wrapper); + assert.isFalse(isListboxClosed(wrapper)); + + clickOption(wrapper, 1); + + // After clicking an option, the listbox is still open + assert.isFalse(isListboxClosed(wrapper)); + }); + + it('allows multiple items to be selected when the value is an array', () => { + const onChange = sinon.stub(); + const wrapper = createComponent({ + multiple: true, + value: [items[0], items[2]], + onChange, + }); + + toggleListbox(wrapper); + clickOption(wrapper, 2); + + // When a not-yet-selected item is clicked, it will be selected + assert.calledWith(onChange, [items[0], items[2], items[1]]); + }); + + it('allows deselecting already selected options', () => { + const onChange = sinon.stub(); + const wrapper = createComponent({ + multiple: true, + value: [items[0], items[2]], + onChange, + }); + + toggleListbox(wrapper); + clickOption(wrapper, 3); + + // When an already selected item is clicked, it will be de-selected + assert.calledWith(onChange, [items[0]]); + }); + + it('resets selection when option value is nullish and select value is an array', () => { + const onChange = sinon.stub(); + const wrapper = createComponent({ + multiple: true, + value: [items[0], items[2]], + onChange, + }); + + toggleListbox(wrapper); + wrapper.find(`[data-testid="reset-option"]`).simulate('click'); + + assert.calledWith(onChange, []); + }); + }); + it( 'should pass a11y checks', checkAccessibility([ @@ -405,6 +474,23 @@ describe('SelectNext', () => { ); toggleListbox(wrapper); + return wrapper; + }, + }, + { + name: 'Open Multi-Select listbox', + content: () => { + const wrapper = createComponent( + { + buttonContent: 'Select', + 'aria-label': 'Select', + value: [items[1], items[3]], + multiple: true, + }, + { optionsChildrenAsCallback: false }, + ); + toggleListbox(wrapper); + return wrapper; }, }, diff --git a/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx b/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx index 74bf7c936..1c0a26b64 100644 --- a/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx +++ b/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx @@ -425,6 +425,31 @@ export default function SelectNextPage() { withSource /> + + + + Determines if more than one item can be selected at once, + causing the listbox to stay open when an option is selected on + it. +

    + When multi-selection is enabled, the value must + be an array and onChange will receive an array as + an argument. +

    +
    + + boolean + + + false + +
    + +
    diff --git a/src/pattern-library/examples/select-next-multiple.tsx b/src/pattern-library/examples/select-next-multiple.tsx new file mode 100644 index 000000000..9034b4b41 --- /dev/null +++ b/src/pattern-library/examples/select-next-multiple.tsx @@ -0,0 +1,49 @@ +import { useId, useState } from 'preact/hooks'; + +import { SelectNext } from '../..'; + +type ItemType = { + id: string; + name: string; +}; + +const items: ItemType[] = [ + { id: '1', name: 'John Doe' }, + { id: '2', name: 'Albert Banana' }, + { id: '3', name: 'Bernard California' }, + { id: '4', name: 'Cecelia Davenport' }, + { id: '5', name: 'Doris Evanescence' }, +]; + +export default function App() { + const [values, setSelected] = useState([items[0], items[3]]); + const selectId = useId(); + + return ( +
    + + All students + ) : values.length === 1 ? ( + values[0].name + ) : ( + <>{values.length} students selected + ) + } + > + All students + {items.map(item => ( + + {item.name} + + ))} + +
    + ); +}