-
Notifications
You must be signed in to change notification settings - Fork 4
Allow multiple selection in SelectNext #1601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,21 @@ | ||
| import { createContext } from 'preact'; | ||
|
|
||
| export type SelectContextType<T = unknown> = { | ||
| type SingleSelectContext<T> = { | ||
| selectValue: (newValue: T) => void; | ||
| value: T; | ||
| multiple: false; | ||
| }; | ||
|
|
||
| type MultiSelectContext<T> = { | ||
| selectValue: (newValue: T[]) => void; | ||
| value: T[]; | ||
| multiple: true; | ||
| }; | ||
|
|
||
| export type SelectContextType<T = unknown> = | ||
| | SingleSelectContext<T> | ||
| | MultiSelectContext<T>; | ||
|
|
||
| const SelectContext = createContext<SelectContextType | null>(null); | ||
|
|
||
| export default SelectContext; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,7 +27,12 @@ export type SelectOptionStatus = { | |
| }; | ||
|
|
||
| export type SelectOptionProps<T> = { | ||
| value: T; | ||
| /** | ||
| * An undefined value in a multiple select will cause the selection to reset | ||
| * to an empty array. | ||
| */ | ||
| value: T | undefined; | ||
|
|
||
| disabled?: boolean; | ||
| children: | ||
| | ComponentChildren | ||
|
|
@@ -59,8 +64,34 @@ function SelectOption<T>({ | |
| throw new Error('Select.Option can only be used as Select child'); | ||
| } | ||
|
|
||
| const { selectValue, value: currentValue } = selectContext; | ||
| const selected = !disabled && currentValue === value; | ||
| const { selectValue, value: currentValue, multiple } = selectContext; | ||
| const selected = | ||
| !disabled && | ||
| ((multiple && currentValue.includes(value)) || currentValue === value); | ||
|
|
||
| const selectOrToggle = useCallback(() => { | ||
| // In single-select, just set current value | ||
| if (!multiple) { | ||
| selectValue(value); | ||
| return; | ||
| } | ||
|
|
||
| // In multi-select, clear selection for nullish values | ||
| if (!value) { | ||
| selectValue([]); | ||
| return; | ||
| } | ||
|
Comment on lines
+80
to
+83
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a bit opinionated and not entirely obvious behavior. Perhaps a better option would be to allow
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suggested above that it might help to avoid errors if the component just threw an error if in multi-select mode and a non-array value is passed. |
||
|
|
||
| // 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, multiple, selectValue, value]); | ||
|
|
||
| return ( | ||
| <li | ||
|
|
@@ -75,13 +106,13 @@ function SelectOption<T>({ | |
| )} | ||
| onClick={() => { | ||
| if (!disabled) { | ||
| selectValue(value); | ||
| selectOrToggle(); | ||
| } | ||
| }} | ||
| onKeyPress={e => { | ||
| if (!disabled && ['Enter', 'Space'].includes(e.code)) { | ||
| e.preventDefault(); | ||
| selectValue(value); | ||
| selectOrToggle(); | ||
| } | ||
| }} | ||
| role="option" | ||
|
|
@@ -215,42 +246,59 @@ function useListboxPositioning( | |
| }, [adjustListboxPositioning, asPopover]); | ||
| } | ||
|
|
||
| export type SelectProps<T> = CompositeProps & { | ||
| type SingleValueProps<T> = { | ||
| 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; | ||
| type MultiValueProps<T> = { | ||
| value: T[]; | ||
| onChange: (newValue: T[]) => void; | ||
| }; | ||
|
|
||
| /** 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[]; | ||
| export type SelectProps<T> = CompositeProps & | ||
| (SingleValueProps<T> | MultiValueProps<T>) & { | ||
| buttonContent?: ComponentChildren; | ||
| disabled?: 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; | ||
| /** | ||
| * 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; | ||
|
|
||
| 'aria-label'?: string; | ||
| 'aria-labelledby'?: string; | ||
| /** | ||
| * `id` attribute for the toggle button. This is useful to associate a label | ||
| * with the control. | ||
| */ | ||
| buttonId?: string; | ||
|
|
||
| /** | ||
| * Used to determine if the listbox should use the popover API. | ||
| * Defaults to true, as long as the browser supports it. | ||
| */ | ||
| listboxAsPopover?: boolean; | ||
| }; | ||
| /** 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<T>({ | ||
| buttonContent, | ||
|
|
@@ -264,11 +312,16 @@ function SelectMain<T>({ | |
| 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<T>) { | ||
| if (multiple && !Array.isArray(value)) { | ||
| throw new Error('When `multiple` is true, the value must be an array'); | ||
| } | ||
|
|
||
| const wrapperRef = useRef<HTMLDivElement | null>(null); | ||
| const listboxRef = useRef<HTMLUListElement | null>(null); | ||
| const [listboxOpen, setListboxOpen] = useState(false); | ||
|
|
@@ -295,11 +348,14 @@ function SelectMain<T>({ | |
| ); | ||
|
|
||
| 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 | ||
|
|
@@ -369,7 +425,15 @@ function SelectMain<T>({ | |
| {listboxOpen ? <MenuCollapseIcon /> : <MenuExpandIcon />} | ||
| </div> | ||
| </button> | ||
| <SelectContext.Provider value={{ selectValue, value }}> | ||
|
|
||
| <SelectContext.Provider | ||
| value={{ | ||
| // Explicit type casting needed here | ||
| value: value as typeof multiple extends false ? T : T[], | ||
| selectValue, | ||
| multiple, | ||
| }} | ||
| > | ||
| <ul | ||
| className={classnames( | ||
| 'absolute z-5 max-h-80 overflow-y-auto', | ||
|
|
@@ -387,6 +451,7 @@ function SelectMain<T>({ | |
| role="listbox" | ||
| ref={listboxRef} | ||
| id={listboxId} | ||
| aria-multiselectable={multiple} | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| aria-labelledby={buttonId ?? defaultButtonId} | ||
| aria-orientation="vertical" | ||
| data-testid="select-listbox" | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.