Skip to content

feat: Add support for multiple selection to Select and Picker #8734

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 53 additions & 31 deletions packages/@react-aria/select/src/HiddenSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
* governing permissions and limitations under the License.
*/

import {FocusableElement, RefObject} from '@react-types/shared';
import {FocusableElement, Key, RefObject} from '@react-types/shared';
import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'react';
import {selectData} from './useSelect';
import {SelectionMode} from '@react-types/select';
import {SelectState} from '@react-stately/select';
import {useFormReset} from '@react-aria/utils';
import {useFormValidation} from '@react-aria/form';
Expand Down Expand Up @@ -41,9 +42,9 @@ export interface AriaHiddenSelectProps {
isDisabled?: boolean
}

export interface HiddenSelectProps<T> extends AriaHiddenSelectProps {
export interface HiddenSelectProps<T, M extends SelectionMode = 'single'> extends AriaHiddenSelectProps {
/** State for the select. */
state: SelectState<T>,
state: SelectState<T, M>,

/** A ref to the trigger element. */
triggerRef: RefObject<FocusableElement | null>
Expand All @@ -70,7 +71,7 @@ export interface HiddenSelectAria {
* can be used in combination with `useSelect` to support browser form autofill, mobile form
* navigation, and native HTML form submission.
*/
export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: SelectState<T>, triggerRef: RefObject<FocusableElement | null>): HiddenSelectAria {
export function useHiddenSelect<T, M extends SelectionMode = 'single'>(props: AriaHiddenSelectOptions, state: SelectState<T, M>, triggerRef: RefObject<FocusableElement | null>): HiddenSelectAria {
let data = selectData.get(state) || {};
let {autoComplete, name = data.name, form = data.form, isDisabled = data.isDisabled} = props;
let {validationBehavior, isRequired} = data;
Expand All @@ -83,14 +84,23 @@ export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: Select
}
});

useFormReset(props.selectRef, state.defaultSelectedKey, state.setSelectedKey);
useFormReset(props.selectRef, state.defaultValue, state.setValue);
useFormValidation({
validationBehavior,
focus: () => triggerRef.current?.focus()
}, state, props.selectRef);

// eslint-disable-next-line react-hooks/exhaustive-deps
let onChange = useCallback((e: React.ChangeEvent<HTMLSelectElement> | React.FormEvent<HTMLSelectElement>) => state.setSelectedKey(e.currentTarget.value), [state.setSelectedKey]);
let setValue = state.setValue;
let onChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
if (e.target.multiple) {
setValue(Array.from(
e.target.selectedOptions,
(option) => option.value
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think currently we allow for Key which is string | number
but this would change it to string only right?

) as any);
} else {
setValue(e.currentTarget.value as any);
}
}, [setValue]);

// In Safari, the <select> cannot have `display: none` or `hidden` for autofill to work.
// In Firefox, there must be a <label> to identify the <select> whereas other browsers
Expand All @@ -114,10 +124,11 @@ export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: Select
tabIndex: -1,
autoComplete,
disabled: isDisabled,
multiple: state.selectionManager.selectionMode === 'multiple',
required: validationBehavior === 'native' && isRequired,
name,
form,
value: state.selectedKey ?? '',
value: (state.value as string | string[]) ?? '',
onChange,
onInput: onChange
}
Expand All @@ -128,7 +139,7 @@ export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: Select
* Renders a hidden native `<select>` element, which can be used to support browser
* form autofill, mobile form navigation, and native form submission.
*/
export function HiddenSelect<T>(props: HiddenSelectProps<T>): JSX.Element | null {
export function HiddenSelect<T, M extends SelectionMode = 'single'>(props: HiddenSelectProps<T, M>): JSX.Element | null {
let {state, triggerRef, label, name, form, isDisabled} = props;
let selectRef = useRef(null);
let inputRef = useRef(null);
Expand Down Expand Up @@ -164,32 +175,43 @@ export function HiddenSelect<T>(props: HiddenSelectProps<T>): JSX.Element | null
let data = selectData.get(state) || {};
let {validationBehavior} = data;

let inputProps: InputHTMLAttributes<HTMLInputElement> = {
type: 'hidden',
autoComplete: selectProps.autoComplete,
name,
form,
disabled: isDisabled,
value: state.selectedKey ?? ''
};
// Always render at least one hidden input to ensure required form submission.
let values: (Key | null)[] = Array.isArray(state.value) ? state.value : [state.value];
if (values.length === 0) {
values = [null];
}

let res = values.map((value, i) => {
let inputProps: InputHTMLAttributes<HTMLInputElement> = {
type: 'hidden',
autoComplete: selectProps.autoComplete,
name,
form,
disabled: isDisabled,
value: value ?? ''
};

if (validationBehavior === 'native') {
// Use a hidden <input type="text"> rather than <input type="hidden">
// so that an empty value blocks HTML form submission when the field is required.
return (
<input
key={i}
{...inputProps}
ref={i === 0 ? inputRef : null}
style={{display: 'none'}}
type="text"
required={i === 0 ? selectProps.required : false}
onChange={() => {/** Ignore react warning. */}} />
);
}

if (validationBehavior === 'native') {
// Use a hidden <input type="text"> rather than <input type="hidden">
// so that an empty value blocks HTML form submission when the field is required.
return (
<input
{...inputProps}
ref={inputRef}
style={{display: 'none'}}
type="text"
required={selectProps.required}
onChange={() => {/** Ignore react warning. */}} />
<input key={i} {...inputProps} ref={i === 0 ? inputRef : null} />
);
}
});

return (
<input {...inputProps} ref={inputRef} />
);
return <>{res}</>;
}

return null;
Expand Down
19 changes: 13 additions & 6 deletions packages/@react-aria/select/src/useSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {AriaButtonProps} from '@react-types/button';
import {AriaListBoxOptions} from '@react-aria/listbox';
import {AriaSelectProps} from '@react-types/select';
import {AriaSelectProps, SelectionMode} from '@react-types/select';
import {chain, filterDOMProps, mergeProps, useId} from '@react-aria/utils';
import {DOMAttributes, KeyboardDelegate, RefObject, ValidationResult} from '@react-types/shared';
import {FocusEvent, useMemo} from 'react';
Expand All @@ -24,15 +24,15 @@ import {useCollator} from '@react-aria/i18n';
import {useField} from '@react-aria/label';
import {useMenuTrigger} from '@react-aria/menu';

export interface AriaSelectOptions<T> extends Omit<AriaSelectProps<T>, 'children'> {
export interface AriaSelectOptions<T, M extends SelectionMode = 'single'> extends Omit<AriaSelectProps<T, M>, 'children'> {
/**
* An optional keyboard delegate implementation for type to select,
* to override the default.
*/
keyboardDelegate?: KeyboardDelegate
}

export interface SelectAria<T> extends ValidationResult {
export interface SelectAria<T, M extends SelectionMode = 'single'> extends ValidationResult {
/** Props for the label element. */
labelProps: DOMAttributes,

Expand All @@ -52,7 +52,7 @@ export interface SelectAria<T> extends ValidationResult {
errorMessageProps: DOMAttributes,

/** Props for the hidden select element. */
hiddenSelectProps: HiddenSelectProps<T>
hiddenSelectProps: HiddenSelectProps<T, M>
}

interface SelectData {
Expand All @@ -63,15 +63,15 @@ interface SelectData {
validationBehavior?: 'aria' | 'native'
}

export const selectData: WeakMap<SelectState<any>, SelectData> = new WeakMap<SelectState<any>, SelectData>();
export const selectData: WeakMap<SelectState<any, any>, SelectData> = new WeakMap<SelectState<any>, SelectData>();

/**
* Provides the behavior and accessibility implementation for a select component.
* A select displays a collapsible list of options and allows a user to select one of them.
* @param props - Props for the select.
* @param state - State for the select, as returned by `useListState`.
*/
export function useSelect<T>(props: AriaSelectOptions<T>, state: SelectState<T>, ref: RefObject<HTMLElement | null>): SelectAria<T> {
export function useSelect<T, M extends SelectionMode = 'single'>(props: AriaSelectOptions<T, M>, state: SelectState<T, M>, ref: RefObject<HTMLElement | null>): SelectAria<T, M> {
let {
keyboardDelegate,
isDisabled,
Expand All @@ -96,6 +96,10 @@ export function useSelect<T>(props: AriaSelectOptions<T>, state: SelectState<T>,
);

let onKeyDown = (e: KeyboardEvent) => {
if (state.selectionManager.selectionMode === 'multiple') {
return;
}

switch (e.key) {
case 'ArrowLeft': {
// prevent scrolling containers
Expand Down Expand Up @@ -138,6 +142,9 @@ export function useSelect<T>(props: AriaSelectOptions<T>, state: SelectState<T>,

typeSelectProps.onKeyDown = typeSelectProps.onKeyDownCapture;
delete typeSelectProps.onKeyDownCapture;
if (state.selectionManager.selectionMode === 'multiple') {
typeSelectProps = {};
}

let domProps = filterDOMProps(props, {labelable: true});
let triggerProps = mergeProps(typeSelectProps, menuTriggerProps, fieldProps);
Expand Down
4 changes: 3 additions & 1 deletion packages/@react-aria/test-utils/src/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,8 @@ export class SelectTester {
throw new Error('Target option not found in the listbox.');
}

let isMultiSelect = listbox.getAttribute('aria-multiselectable') === 'true';

if (interactionType === 'keyboard') {
if (option?.getAttribute('aria-disabled') === 'true') {
return;
Expand All @@ -203,7 +205,7 @@ export class SelectTester {
}
}

if (option?.getAttribute('href') == null) {
if (!isMultiSelect && option?.getAttribute('href') == null) {
await waitFor(() => {
if (document.activeElement !== this._trigger) {
throw new Error(`Expected the document.activeElement after selecting an option to be the select component trigger but got ${document.activeElement}`);
Expand Down
3 changes: 1 addition & 2 deletions packages/@react-spectrum/picker/test/Picker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1508,8 +1508,7 @@ describe('Picker', function () {
expect(document.activeElement).toBe(items[1]);

await selectTester.selectOption({option: 'Two'});
expect(onSelectionChange).toHaveBeenCalledTimes(1);
expect(onSelectionChange).toHaveBeenCalledWith('two');
expect(onSelectionChange).not.toHaveBeenCalled();

expect(document.activeElement).toBe(picker);
expect(picker).toHaveTextContent('Two');
Expand Down
11 changes: 6 additions & 5 deletions packages/@react-spectrum/s2/src/Picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,9 @@ export interface PickerStyleProps {
isQuiet?: boolean
}

export interface PickerProps<T extends object> extends
Omit<AriaSelectProps<T>, 'children' | 'style' | 'className' | keyof GlobalDOMAttributes>,
type SelectionMode = 'single' | 'multiple';
export interface PickerProps<T extends object, M extends SelectionMode = 'single'> extends
Omit<AriaSelectProps<T, M>, 'children' | 'style' | 'className' | keyof GlobalDOMAttributes>,
PickerStyleProps,
StyleProps,
SpectrumLabelableProps,
Expand Down Expand Up @@ -262,7 +263,7 @@ let InsideSelectValueContext = createContext(false);
/**
* Pickers allow users to choose a single option from a collapsible list of options when space is limited.
*/
export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Picker<T extends object>(props: PickerProps<T>, ref: FocusableRef<HTMLButtonElement>) {
export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Picker<T extends object, M extends SelectionMode = 'single'>(props: PickerProps<T, M>, ref: FocusableRef<HTMLButtonElement>) {
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');
[props, ref] = useSpectrumContextProps(props, ref, PickerContext);
let domRef = useFocusableRef(ref);
Expand Down Expand Up @@ -507,7 +508,7 @@ const PickerButton = createHideableComponent(function PickerButton<T extends obj
{(renderProps) => (
<>
<SelectValue className={valueStyles({isQuiet}) + ' ' + raw('&> * {display: none;}')}>
{({defaultChildren}) => {
{({selectedItems, defaultChildren, selectedText}) => {
return (
<Provider
values={[
Expand All @@ -531,7 +532,7 @@ const PickerButton = createHideableComponent(function PickerButton<T extends obj
}],
[InsideSelectValueContext, true]
]}>
{defaultChildren}
{selectedItems.length <= 1 ? defaultChildren : <Text slot="label">{selectedText}</Text>}
</Provider>
);
}}
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/s2/stories/Picker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const meta: Meta<typeof Picker<any>> = {
decorators: [StaticColorDecorator],
tags: ['autodocs'],
argTypes: {
...categorizeArgTypes('Events', ['onOpenChange', 'onSelectionChange']),
...categorizeArgTypes('Events', ['onOpenChange', 'onChange']),
label: {control: {type: 'text'}},
description: {control: {type: 'text'}},
errorMessage: {control: {type: 'text'}},
Expand Down
1 change: 0 additions & 1 deletion packages/@react-stately/combobox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"@react-stately/form": "^3.2.0",
"@react-stately/list": "^3.12.4",
"@react-stately/overlays": "^3.6.18",
"@react-stately/select": "^3.7.0",
"@react-stately/utils": "^3.10.8",
"@react-types/combobox": "^3.13.7",
"@react-types/shared": "^3.31.0",
Expand Down
13 changes: 9 additions & 4 deletions packages/@react-stately/combobox/src/useComboBoxState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@ import {Collection, CollectionStateBase, FocusStrategy, Key, Node} from '@react-
import {ComboBoxProps, MenuTriggerAction} from '@react-types/combobox';
import {FormValidationState, useFormValidationState} from '@react-stately/form';
import {getChildNodes} from '@react-stately/collections';
import {ListCollection, useSingleSelectListState} from '@react-stately/list';
import {SelectState} from '@react-stately/select';
import {ListCollection, SingleSelectListState, useSingleSelectListState} from '@react-stately/list';
import {OverlayTriggerState, useOverlayTriggerState} from '@react-stately/overlays';
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useControlledState} from '@react-stately/utils';
import {useOverlayTriggerState} from '@react-stately/overlays';

export interface ComboBoxState<T> extends SelectState<T>, FormValidationState{
export interface ComboBoxState<T> extends SingleSelectListState<T>, OverlayTriggerState, FormValidationState {
/** The default selected key. */
readonly defaultSelectedKey: Key | null,
/** The current value of the combo box input. */
inputValue: string,
/** The default value of the combo box input. */
Expand All @@ -31,6 +32,10 @@ export interface ComboBoxState<T> extends SelectState<T>, FormValidationState{
commit(): void,
/** Controls which item will be auto focused when the menu opens. */
readonly focusStrategy: FocusStrategy | null,
/** Whether the select is currently focused. */
readonly isFocused: boolean,
/** Sets whether the select is focused. */
setFocused(isFocused: boolean): void,
/** Opens the menu. */
open(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void,
/** Toggles the menu. */
Expand Down
1 change: 1 addition & 0 deletions packages/@react-stately/select/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@react-stately/form": "^3.2.0",
"@react-stately/list": "^3.12.4",
"@react-stately/overlays": "^3.6.18",
"@react-stately/utils": "^3.10.8",
"@react-types/select": "^3.10.0",
"@react-types/shared": "^3.31.0",
"@swc/helpers": "^0.5.0"
Expand Down
Loading