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..814bb00ff 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,6 +308,7 @@ function SelectMain({ listboxClasses, containerClasses, right = false, + multiple = false, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, /* eslint-disable-next-line no-prototype-builtins */ @@ -295,11 +340,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 diff --git a/src/components/input/test/SelectNext-test.js b/src/components/input/test/SelectNext-test.js index c2488422f..62a47359f 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,61 @@ describe('SelectNext', () => { }); }); + context('when multi-selection is enabled', () => { + it('keeps listbox open when an option is selected if multiple is true', async () => { + const wrapper = createComponent({ multiple: true }); + + 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({ + 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({ + 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({ + 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 +464,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..631c0d073 100644 --- a/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx +++ b/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx @@ -425,6 +425,39 @@ 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. +

    Considerations:

    +
      +
    • + When multi-selection is enabled, the value{' '} + should be an array instead of a single item. This will cause + every option to be {'"toggable"'} instead of having single + selection. +
    • +
    • + Managing the state and representing the list of selected + items is up to consumers, like with single selection. +
    • +
    +
    + + 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} + + ))} + +
    + ); +}