Skip to content

Commit dc6b203

Browse files
committed
feat: Add support for multiple selection to Select
1 parent 7ee8d64 commit dc6b203

File tree

17 files changed

+507
-132
lines changed

17 files changed

+507
-132
lines changed

packages/@react-aria/select/src/HiddenSelect.tsx

Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {FocusableElement, RefObject} from '@react-types/shared';
13+
import {FocusableElement, Key, RefObject} from '@react-types/shared';
1414
import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'react';
1515
import {selectData} from './useSelect';
16+
import {SelectionMode} from '@react-types/select';
1617
import {SelectState} from '@react-stately/select';
1718
import {useFormReset} from '@react-aria/utils';
1819
import {useFormValidation} from '@react-aria/form';
@@ -41,9 +42,9 @@ export interface AriaHiddenSelectProps {
4142
isDisabled?: boolean
4243
}
4344

44-
export interface HiddenSelectProps<T> extends AriaHiddenSelectProps {
45+
export interface HiddenSelectProps<T, M extends SelectionMode = 'single'> extends AriaHiddenSelectProps {
4546
/** State for the select. */
46-
state: SelectState<T>,
47+
state: SelectState<T, M>,
4748

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

86-
useFormReset(props.selectRef, state.defaultSelectedKey, state.setSelectedKey);
87+
useFormReset(props.selectRef, state.defaultValue, state.setValue);
8788
useFormValidation({
8889
validationBehavior,
8990
focus: () => triggerRef.current?.focus()
9091
}, state, props.selectRef);
9192

92-
// eslint-disable-next-line react-hooks/exhaustive-deps
93-
let onChange = useCallback((e: React.ChangeEvent<HTMLSelectElement> | React.FormEvent<HTMLSelectElement>) => state.setSelectedKey(e.currentTarget.value), [state.setSelectedKey]);
93+
let setValue = state.setValue;
94+
let onChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
95+
if (e.target.multiple) {
96+
setValue(Array.from(
97+
e.target.selectedOptions,
98+
(option) => option.value
99+
) as any);
100+
} else {
101+
setValue(e.currentTarget.value as any);
102+
}
103+
}, [setValue]);
94104

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

167-
let inputProps: InputHTMLAttributes<HTMLInputElement> = {
168-
type: 'hidden',
169-
autoComplete: selectProps.autoComplete,
170-
name,
171-
form,
172-
disabled: isDisabled,
173-
value: state.selectedKey ?? ''
174-
};
178+
// Always render at least one hidden input to ensure required form submission.
179+
let values: (Key | null)[] = Array.isArray(state.value) ? state.value : [state.value];
180+
if (values.length === 0) {
181+
values = [null];
182+
}
183+
184+
let res = values.map((value, i) => {
185+
let inputProps: InputHTMLAttributes<HTMLInputElement> = {
186+
type: 'hidden',
187+
autoComplete: selectProps.autoComplete,
188+
name,
189+
form,
190+
disabled: isDisabled,
191+
value: value ?? ''
192+
};
193+
194+
if (validationBehavior === 'native') {
195+
// Use a hidden <input type="text"> rather than <input type="hidden">
196+
// so that an empty value blocks HTML form submission when the field is required.
197+
return (
198+
<input
199+
key={i}
200+
{...inputProps}
201+
ref={i === 0 ? inputRef : null}
202+
style={{display: 'none'}}
203+
type="text"
204+
required={i === 0 ? selectProps.required : false}
205+
onChange={() => {/** Ignore react warning. */}} />
206+
);
207+
}
175208

176-
if (validationBehavior === 'native') {
177-
// Use a hidden <input type="text"> rather than <input type="hidden">
178-
// so that an empty value blocks HTML form submission when the field is required.
179209
return (
180-
<input
181-
{...inputProps}
182-
ref={inputRef}
183-
style={{display: 'none'}}
184-
type="text"
185-
required={selectProps.required}
186-
onChange={() => {/** Ignore react warning. */}} />
210+
<input key={i} {...inputProps} ref={i === 0 ? inputRef : null} />
187211
);
188-
}
212+
});
189213

190-
return (
191-
<input {...inputProps} ref={inputRef} />
192-
);
214+
return <>{res}</>;
193215
}
194216

195217
return null;

packages/@react-aria/select/src/useSelect.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

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

27-
export interface AriaSelectOptions<T> extends Omit<AriaSelectProps<T>, 'children'> {
27+
export interface AriaSelectOptions<T, M extends SelectionMode = 'single'> extends Omit<AriaSelectProps<T, M>, 'children'> {
2828
/**
2929
* An optional keyboard delegate implementation for type to select,
3030
* to override the default.
3131
*/
3232
keyboardDelegate?: KeyboardDelegate
3333
}
3434

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

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

5454
/** Props for the hidden select element. */
55-
hiddenSelectProps: HiddenSelectProps<T>
55+
hiddenSelectProps: HiddenSelectProps<T, M>
5656
}
5757

5858
interface SelectData {
@@ -63,15 +63,15 @@ interface SelectData {
6363
validationBehavior?: 'aria' | 'native'
6464
}
6565

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

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

9898
let onKeyDown = (e: KeyboardEvent) => {
99+
if (state.selectionManager.selectionMode === 'multiple') {
100+
return;
101+
}
102+
99103
switch (e.key) {
100104
case 'ArrowLeft': {
101105
// prevent scrolling containers
@@ -138,6 +142,9 @@ export function useSelect<T>(props: AriaSelectOptions<T>, state: SelectState<T>,
138142

139143
typeSelectProps.onKeyDown = typeSelectProps.onKeyDownCapture;
140144
delete typeSelectProps.onKeyDownCapture;
145+
if (state.selectionManager.selectionMode === 'multiple') {
146+
typeSelectProps = {};
147+
}
141148

142149
let domProps = filterDOMProps(props, {labelable: true});
143150
let triggerProps = mergeProps(typeSelectProps, menuTriggerProps, fieldProps);

packages/@react-aria/test-utils/src/select.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ export class SelectTester {
184184
throw new Error('Target option not found in the listbox.');
185185
}
186186

187+
let isMultiSelect = listbox.getAttribute('aria-multiselectable') === 'true';
188+
187189
if (interactionType === 'keyboard') {
188190
if (option?.getAttribute('aria-disabled') === 'true') {
189191
return;
@@ -203,7 +205,7 @@ export class SelectTester {
203205
}
204206
}
205207

206-
if (option?.getAttribute('href') == null) {
208+
if (!isMultiSelect && option?.getAttribute('href') == null) {
207209
await waitFor(() => {
208210
if (document.activeElement !== this._trigger) {
209211
throw new Error(`Expected the document.activeElement after selecting an option to be the select component trigger but got ${document.activeElement}`);

packages/@react-spectrum/picker/test/Picker.test.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,8 +1508,7 @@ describe('Picker', function () {
15081508
expect(document.activeElement).toBe(items[1]);
15091509

15101510
await selectTester.selectOption({option: 'Two'});
1511-
expect(onSelectionChange).toHaveBeenCalledTimes(1);
1512-
expect(onSelectionChange).toHaveBeenCalledWith('two');
1511+
expect(onSelectionChange).not.toHaveBeenCalled();
15131512

15141513
expect(document.activeElement).toBe(picker);
15151514
expect(picker).toHaveTextContent('Two');

packages/@react-spectrum/s2/src/Picker.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,9 @@ export interface PickerStyleProps {
9191
isQuiet?: boolean
9292
}
9393

94-
export interface PickerProps<T extends object> extends
95-
Omit<AriaSelectProps<T>, 'children' | 'style' | 'className' | keyof GlobalDOMAttributes>,
94+
type SelectionMode = 'single' | 'multiple';
95+
export interface PickerProps<T extends object, M extends SelectionMode = 'single'> extends
96+
Omit<AriaSelectProps<T, M>, 'children' | 'style' | 'className' | keyof GlobalDOMAttributes>,
9697
PickerStyleProps,
9798
StyleProps,
9899
SpectrumLabelableProps,
@@ -262,7 +263,7 @@ let InsideSelectValueContext = createContext(false);
262263
/**
263264
* Pickers allow users to choose a single option from a collapsible list of options when space is limited.
264265
*/
265-
export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Picker<T extends object>(props: PickerProps<T>, ref: FocusableRef<HTMLButtonElement>) {
266+
export const Picker = /*#__PURE__*/ (forwardRef as forwardRefType)(function Picker<T extends object, M extends SelectionMode = 'single'>(props: PickerProps<T, M>, ref: FocusableRef<HTMLButtonElement>) {
266267
let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/s2');
267268
[props, ref] = useSpectrumContextProps(props, ref, PickerContext);
268269
let domRef = useFocusableRef(ref);
@@ -507,7 +508,7 @@ const PickerButton = createHideableComponent(function PickerButton<T extends obj
507508
{(renderProps) => (
508509
<>
509510
<SelectValue className={valueStyles({isQuiet}) + ' ' + raw('&> * {display: none;}')}>
510-
{({defaultChildren}) => {
511+
{({selectedItems, defaultChildren, selectedText}) => {
511512
return (
512513
<Provider
513514
values={[
@@ -531,7 +532,7 @@ const PickerButton = createHideableComponent(function PickerButton<T extends obj
531532
}],
532533
[InsideSelectValueContext, true]
533534
]}>
534-
{defaultChildren}
535+
{selectedItems.length <= 1 ? defaultChildren : <Text slot="label">{selectedText}</Text>}
535536
</Provider>
536537
);
537538
}}

packages/@react-spectrum/s2/stories/Picker.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const meta: Meta<typeof Picker<any>> = {
4141
decorators: [StaticColorDecorator],
4242
tags: ['autodocs'],
4343
argTypes: {
44-
...categorizeArgTypes('Events', ['onOpenChange', 'onSelectionChange']),
44+
...categorizeArgTypes('Events', ['onOpenChange', 'onChange']),
4545
label: {control: {type: 'text'}},
4646
description: {control: {type: 'text'}},
4747
errorMessage: {control: {type: 'text'}},

packages/@react-stately/combobox/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@
3030
"@react-stately/form": "^3.2.0",
3131
"@react-stately/list": "^3.12.4",
3232
"@react-stately/overlays": "^3.6.18",
33-
"@react-stately/select": "^3.7.0",
3433
"@react-stately/utils": "^3.10.8",
3534
"@react-types/combobox": "^3.13.7",
3635
"@react-types/shared": "^3.31.0",

packages/@react-stately/combobox/src/useComboBoxState.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import {Collection, CollectionStateBase, FocusStrategy, Key, Node} from '@react-
1414
import {ComboBoxProps, MenuTriggerAction} from '@react-types/combobox';
1515
import {FormValidationState, useFormValidationState} from '@react-stately/form';
1616
import {getChildNodes} from '@react-stately/collections';
17-
import {ListCollection, useSingleSelectListState} from '@react-stately/list';
18-
import {SelectState} from '@react-stately/select';
17+
import {ListCollection, SingleSelectListState, useSingleSelectListState} from '@react-stately/list';
18+
import {OverlayTriggerState, useOverlayTriggerState} from '@react-stately/overlays';
1919
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
2020
import {useControlledState} from '@react-stately/utils';
21-
import {useOverlayTriggerState} from '@react-stately/overlays';
2221

23-
export interface ComboBoxState<T> extends SelectState<T>, FormValidationState{
22+
export interface ComboBoxState<T> extends SingleSelectListState<T>, OverlayTriggerState, FormValidationState {
23+
/** The default selected key. */
24+
readonly defaultSelectedKey: Key | null,
2425
/** The current value of the combo box input. */
2526
inputValue: string,
2627
/** The default value of the combo box input. */
@@ -31,6 +32,10 @@ export interface ComboBoxState<T> extends SelectState<T>, FormValidationState{
3132
commit(): void,
3233
/** Controls which item will be auto focused when the menu opens. */
3334
readonly focusStrategy: FocusStrategy | null,
35+
/** Whether the select is currently focused. */
36+
readonly isFocused: boolean,
37+
/** Sets whether the select is focused. */
38+
setFocused(isFocused: boolean): void,
3439
/** Opens the menu. */
3540
open(focusStrategy?: FocusStrategy | null, trigger?: MenuTriggerAction): void,
3641
/** Toggles the menu. */

packages/@react-stately/select/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@react-stately/form": "^3.2.0",
3030
"@react-stately/list": "^3.12.4",
3131
"@react-stately/overlays": "^3.6.18",
32+
"@react-stately/utils": "^3.10.8",
3233
"@react-types/select": "^3.10.0",
3334
"@react-types/shared": "^3.31.0",
3435
"@swc/helpers": "^0.5.0"

0 commit comments

Comments
 (0)