diff --git a/src/components/input/SelectNext.tsx b/src/components/input/SelectNext.tsx index dc249967..194094ad 100644 --- a/src/components/input/SelectNext.tsx +++ b/src/components/input/SelectNext.tsx @@ -257,52 +257,57 @@ type MultiValueProps = { onChange: (newValue: T[]) => void; }; -export type SelectProps = CompositeProps & - (SingleValueProps | MultiValueProps) & { - buttonContent?: ComponentChildren; - disabled?: boolean; +type BaseSelectProps = CompositeProps & { + 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 - * and the value must be an array. - * Defaults to false. - */ - multiple?: boolean; + /** + * `id` attribute for the toggle button. This is useful to associate a label + * with the control. + */ + buttonId?: string; - /** - * `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[]; - /** 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; - /** - * 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; - '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; - /** - * Used to determine if the listbox should use the popover API. - * Defaults to true, as long as the browser supports it. - */ - listboxAsPopover?: boolean; + /** A callback passed to the listbox onScroll */ + onListboxScroll?: JSX.HTMLAttributes['onScroll']; +}; + +export type SelectProps = BaseSelectProps & SingleValueProps; - /** A callback passed to the listbox onScroll */ - onListboxScroll?: JSX.HTMLAttributes['onScroll']; - }; +export type MultiSelectProps = BaseSelectProps & MultiValueProps; + +export type SelectNextProps = (SelectProps | MultiSelectProps) & { + /** + * Whether this select should allow multi-selection or not. + * When this is true, the listbox is kept open when an option is selected + * and the value must be an array. + * Defaults to false. + */ + multiple?: boolean; +}; function SelectMain({ buttonContent, @@ -322,7 +327,7 @@ function SelectMain({ 'aria-labelledby': ariaLabelledBy, /* eslint-disable-next-line no-prototype-builtins */ listboxAsPopover = HTMLElement.prototype.hasOwnProperty('popover'), -}: SelectProps) { +}: SelectNextProps) { if (multiple && !Array.isArray(value)) { throw new Error('When `multiple` is true, the value must be an array'); } @@ -474,8 +479,27 @@ function SelectMain({ ); } -SelectMain.displayName = 'SelectNext'; - -const SelectNext = Object.assign(SelectMain, { Option: SelectOption }); - -export default SelectNext; +export const SelectNext = Object.assign(SelectMain, { + Option: SelectOption, + displayName: 'SelectNext', +}); + +export const Select = Object.assign( + function (props: SelectProps) { + // Calling the function directly instead of returning a JSX element, to + // avoid an unnecessary extra layer in the component tree + // eslint-disable-next-line new-cap + return SelectNext({ ...props, multiple: false }); + }, + { Option: SelectOption, displayName: 'Select' }, +); + +export const MultiSelect = Object.assign( + function (props: MultiSelectProps) { + // Calling the function directly instead of returning a JSX element, to + // avoid an unnecessary extra layer in the component tree + // eslint-disable-next-line new-cap + return SelectNext({ ...props, multiple: true }); + }, + { Option: SelectOption, displayName: 'MultiSelect' }, +); diff --git a/src/components/input/index.ts b/src/components/input/index.ts index fa62c4b5..9cd2e787 100644 --- a/src/components/input/index.ts +++ b/src/components/input/index.ts @@ -5,7 +5,7 @@ export { default as IconButton } from './IconButton'; export { default as Input } from './Input'; export { default as InputGroup } from './InputGroup'; export { default as OptionButton } from './OptionButton'; -export { default as SelectNext } from './SelectNext'; +export { SelectNext, Select, MultiSelect } from './SelectNext'; export { default as Textarea } from './Textarea'; export type { ButtonProps } from './Button'; @@ -15,5 +15,9 @@ export type { IconButtonProps } from './IconButton'; export type { InputProps } from './Input'; export type { InputGroupProps } from './InputGroup'; export type { OptionButtonProps } from './OptionButton'; -export type { SelectProps as SelectNextProps } from './SelectNext'; +export type { + MultiSelectProps, + SelectNextProps, + SelectProps, +} from './SelectNext'; export type { TextareaProps } from './Textarea'; diff --git a/src/components/input/test/SelectNext-test.js b/src/components/input/test/SelectNext-test.js index 0ce81666..772c3ab2 100644 --- a/src/components/input/test/SelectNext-test.js +++ b/src/components/input/test/SelectNext-test.js @@ -1,7 +1,7 @@ import { checkAccessibility, waitFor } from '@hypothesis/frontend-testing'; import { mount } from 'enzyme'; -import SelectNext from '../SelectNext'; +import { MultiSelect, Select, SelectNext } from '../SelectNext'; describe('SelectNext', () => { let wrappers; @@ -21,20 +21,26 @@ describe('SelectNext', () => { * Whether to renders SelectNext.Option children with callback notation. * Used primarily to test and cover both branches. * Defaults to true. + * @param {MultiSelect | Select | SelectNext} [options.Component] - + * The actual "select" component to use. Defaults to `SelectNext`. */ const createComponent = (props = {}, options = {}) => { - const { paddingTop = 0, optionsChildrenAsCallback = true } = options; + const { + paddingTop = 0, + optionsChildrenAsCallback = true, + Component = SelectNext, + } = options; const container = document.createElement('div'); container.style.paddingTop = `${paddingTop}px`; document.body.append(container); const wrapper = mount( - - + + Reset - + {items.map(item => ( - { ) )} - + ))} - , + , { attachTo: container }, ); @@ -93,18 +99,29 @@ describe('SelectNext', () => { 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 }); + [ + { + title: 'changes selected value when an option is clicked', + }, + { + title: + 'changes selected value when an option is clicked, via Select alias', + options: { Component: Select }, + }, + ].forEach(({ title, options = {} }) => { + it(title, () => { + const onChange = sinon.stub(); + const wrapper = createComponent({ onChange }, options); - clickOption(wrapper, 3); - assert.calledWith(onChange.lastCall, items[2]); + clickOption(wrapper, 3); + assert.calledWith(onChange.lastCall, items[2]); - clickOption(wrapper, 5); - assert.calledWith(onChange.lastCall, items[4]); + clickOption(wrapper, 5); + assert.calledWith(onChange.lastCall, items[4]); - clickOption(wrapper, 1); - assert.calledWith(onChange.lastCall, items[0]); + clickOption(wrapper, 1); + assert.calledWith(onChange.lastCall, items[0]); + }); }); it('does not change selected value when a disabled option is clicked', () => { @@ -409,19 +426,34 @@ describe('SelectNext', () => { 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, - }); + [ + { + title: + 'allows multiple items to be selected when the value is an array', + extraProps: { multiple: true }, + }, + { + title: 'allows same behavior via MultiSelect alias', + options: { Component: MultiSelect }, + }, + ].forEach(({ title, extraProps = {}, options = {} }) => { + it(title, () => { + const onChange = sinon.stub(); + const wrapper = createComponent( + { + value: [items[0], items[2]], + onChange, + ...extraProps, + }, + options, + ); - toggleListbox(wrapper); - clickOption(wrapper, 2); + 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]]); + // 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', () => { diff --git a/src/index.ts b/src/index.ts index ea1dd10d..889a00b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,7 +48,9 @@ export { IconButton, Input, InputGroup, + MultiSelect, OptionButton, + Select, SelectNext, Textarea, } from './components/input'; @@ -115,7 +117,9 @@ export type { IconButtonProps, InputProps, InputGroupProps, + MultiSelectProps, OptionButtonProps, + SelectProps, SelectNextProps, TextareaProps, } from './components/input'; diff --git a/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx b/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx index 773e1887..3950570c 100644 --- a/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx +++ b/src/pattern-library/components/patterns/prototype/SelectNextPage.tsx @@ -3,7 +3,7 @@ import { useId, useState } from 'preact/hooks'; import { Link } from '../../../..'; import type { SelectNextProps } from '../../../../components/input'; -import SelectNext from '../../../../components/input/SelectNext'; +import { SelectNext } from '../../../../components/input/SelectNext'; import SelectNextInInputGroup from '../../../examples/select-next-in-input-group'; import SelectNextWithManyOptions from '../../../examples/select-next-with-custom-options'; import Library from '../../Library'; @@ -99,14 +99,15 @@ export default function SelectNextPage() { title="SelectNext" intro={

- SelectNext is a composite component which behaves like + SelectNext (and its aliases Select and{' '} + MultiSelect) are composite components which behave like the native {' ); } diff --git a/src/pattern-library/examples/select-next-in-input-group.tsx b/src/pattern-library/examples/select-next-in-input-group.tsx index 78dd1866..f276731c 100644 --- a/src/pattern-library/examples/select-next-in-input-group.tsx +++ b/src/pattern-library/examples/select-next-in-input-group.tsx @@ -3,7 +3,7 @@ import { useCallback, useId, useMemo, useState } from 'preact/hooks'; import { ArrowLeftIcon, ArrowRightIcon } from '../../components/icons'; import { IconButton, InputGroup } from '../../components/input'; -import SelectNext from '../../components/input/SelectNext'; +import { SelectNext } from '../../components/input/SelectNext'; const students = [ { id: '1', name: 'All students' }, diff --git a/src/pattern-library/examples/select-next-multi-select.tsx b/src/pattern-library/examples/select-next-multi-select.tsx new file mode 100644 index 00000000..08cc7f86 --- /dev/null +++ b/src/pattern-library/examples/select-next-multi-select.tsx @@ -0,0 +1,48 @@ +import { useId, useState } from 'preact/hooks'; + +import { MultiSelect } 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 ( +

+ + setSelected(newStudents)} + buttonId={selectId} + buttonContent={ + values.length === 0 ? ( + <>All students + ) : values.length === 1 ? ( + values[0].name + ) : ( + <>{values.length} students selected + ) + } + > + All students + {items.map(item => ( + + {item.name} + + ))} + +
+ ); +}