Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
113 changes: 112 additions & 1 deletion packages/react-aria-components/test/Select.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,112 @@ describe('Select', () => {
expect(trigger).toHaveTextContent('Dog');
});

it('should bubble a change event from the hidden select when a ListBox option is selected', async () => {
let onChange = jest.fn();
let {getByTestId} = render(
<form onChange={e => onChange(e.target)}>
<TestSelect name="animal" />
</form>
);
let wrapper = getByTestId('select');
let selectTester = testUtilUser.createTester('Select', {root: wrapper});
let hiddenSelect = document.querySelector('select[name="animal"]');

await selectTester.toggleOptionSelection({option: 'Dog'});

expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(hiddenSelect);
expect(hiddenSelect).toHaveValue('dog');
});

it('should bubble a native change event from the hidden select only once', async () => {
let onChange = jest.fn();
render(
<form onChange={e => onChange(e.target)}>
<TestSelect name="animal" />
</form>
);
let hiddenSelect = document.querySelector('select[name="animal"]');

await user.selectOptions(hiddenSelect, 'dog');

expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(hiddenSelect);
expect(hiddenSelect).toHaveValue('dog');
});

it('should bubble a change event for a controlled select', async () => {
Comment thread
wanxiankai marked this conversation as resolved.
let onChange = jest.fn();
let values = [];

function ControlledSelect() {
let [value, setValue] = useState('cat');
return (
<TestSelect
name="animal"
value={value}
onChange={value => {
onChange(value);
setValue(value);
}}
/>
);
}

let {getByTestId} = render(
<form onChange={e => values.push(e.target.value)}>
<ControlledSelect />
</form>
);
let wrapper = getByTestId('select');
let selectTester = testUtilUser.createTester('Select', {root: wrapper});
let hiddenSelect = document.querySelector('select[name="animal"]');

await selectTester.toggleOptionSelection({option: 'Dog'});

expect(onChange).toHaveBeenCalledWith('dog');
expect(values).toEqual(['dog']);
expect(hiddenSelect).toHaveValue('dog');
});

it('should not bubble a change event when the form is reset', async () => {
let onChange = jest.fn();
let formRef = React.createRef();
let {getByTestId} = render(
<form ref={formRef} onChange={onChange}>
<TestSelect name="animal" defaultValue="cat" />
</form>
);
let wrapper = getByTestId('select');
let selectTester = testUtilUser.createTester('Select', {root: wrapper});
let hiddenSelect = document.querySelector('select[name="animal"]');

await selectTester.toggleOptionSelection({option: 'Dog'});
expect(onChange).toHaveBeenCalledTimes(1);
onChange.mockClear();

act(() => formRef.current.reset());

expect(onChange).not.toHaveBeenCalled();
expect(hiddenSelect).toHaveValue('cat');
});

it('should bubble change events with the selected options for multiple selection', async () => {
let values = [];
let {getByTestId} = render(
<form onChange={e => values.push([...e.target.selectedOptions].map(option => option.value))}>
<TestSelect name="animal" selectionMode="multiple" />
</form>
);
let wrapper = getByTestId('select');
let selectTester = testUtilUser.createTester('Select', {root: wrapper});

await selectTester.toggleOptionSelection({option: 'Cat'});
await selectTester.toggleOptionSelection({option: 'Dog'});

expect(values).toEqual([['cat'], ['cat', 'dog']]);
});

it('should support slot', () => {
let {getByTestId} = render(
<SelectContext.Provider value={{slots: {test: {'aria-label': 'test'}}}}>
Expand Down Expand Up @@ -748,12 +854,16 @@ describe('Select', () => {

it('should support multiple selection form integration with many items', async () => {
let items = [];
let values = [];
for (let i = 0; i < 320; i++) {
items.push({id: i, name: 'item' + i});
}

let {getByTestId} = render(
<Form data-testid="form" onSubmit={e => e.preventDefault()}>
<Form
data-testid="form"
onChange={e => values.push([...e.target.selectedOptions].map(option => option.value))}
onSubmit={e => e.preventDefault()}>
<Select data-testid="select" name="select" selectionMode="multiple" isRequired>
<Label>Select</Label>
<Button>
Expand Down Expand Up @@ -788,6 +898,7 @@ describe('Select', () => {
await user.click(options[1]);
await selectTester.close();
expect(trigger).toHaveTextContent('item0 and item1');
expect(values).toEqual([['0'], ['0', '1']]);

let formData = new FormData(getByTestId('form'));
expect(formData.getAll('select')).toEqual(['0', '1']);
Expand Down
174 changes: 88 additions & 86 deletions packages/react-aria/src/select/HiddenSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {FocusableElement, Key, RefObject} from '@react-types/shared';
import {getEventTarget} from '../utils/shadowdom/DOMFunctions';
import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'react';
import React, {JSX, ReactNode, useCallback, useEffect, useRef} from 'react';
import {selectData} from './useSelect';
import {SelectionMode, SelectState} from 'react-stately/useSelectState';
import {useFormReset} from '../utils/useFormReset';
Expand Down Expand Up @@ -103,18 +103,70 @@ export function useHiddenSelect<T, M extends SelectionMode = 'single'>(
);

let setValue = state.setValue;
let subscribeToValueChange = state.subscribeToValueChange;
let isDispatchingChange = useRef(false);
let onChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
if (isDispatchingChange.current) {
return;
}

let eventTarget = getEventTarget(e);
let value: string | string[];
if (eventTarget.multiple) {
setValue(Array.from(eventTarget.selectedOptions, option => option.value) as any);
value = Array.from(eventTarget.selectedOptions, option => option.value);
} else {
setValue(e.currentTarget.value as any);
value = e.currentTarget.value;
}

setValue(value as any);
},
[setValue]
);

useEffect(() => {
return subscribeToValueChange(value => {
let select = props.selectRef?.current;
if (!(select instanceof HTMLSelectElement)) {
return;
}

let values = (Array.isArray(value) ? value : [value]).map(value => String(value ?? ''));
let temporaryOptions: HTMLOptionElement[] = [];
for (let value of values) {
if (![...select.options].some(option => option.value === value)) {
let option = select.ownerDocument.createElement('option');
option.value = value;
select.add(option);
temporaryOptions.push(option);
}
}

if (select.multiple) {
let selectedValues = new Set(values);
for (let option of select.options) {
option.selected = selectedValues.has(option.value);
}
} else {
let valueSetter = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(select),
'value'
)?.set;
valueSetter?.call(select, values[0] ?? '');
}

isDispatchingChange.current = true;
try {
select.dispatchEvent(new Event('change', {bubbles: true}));
} finally {
for (let option of temporaryOptions) {
option.remove();
}
isDispatchingChange.current = false;
}
});
}, [props.selectRef, subscribeToValueChange]);

// 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
// seem to identify it just by surrounding text.
Expand Down Expand Up @@ -155,91 +207,41 @@ export function useHiddenSelect<T, M extends SelectionMode = 'single'>(
export function HiddenSelect<T, M extends SelectionMode = 'single'>(
props: HiddenSelectProps<T, M>
): JSX.Element | null {
let {state, triggerRef, label, name, form, isDisabled} = props;
let {state, triggerRef, label, name} = props;
let selectRef = useRef(null);
let inputRef = useRef(null);
let {containerProps, selectProps} = useHiddenSelect(
{...props, selectRef: state.collection.size <= 300 ? selectRef : inputRef},
state,
triggerRef
);
let {containerProps, selectProps} = useHiddenSelect({...props, selectRef}, state, triggerRef);

let values: (Key | null)[] = Array.isArray(state.value) ? state.value : [state.value];

// If used in a <form>, use a hidden input so the value can be submitted to a server.
// If the collection isn't too big, use a hidden <select> element for this so that browser
// autofill will work. Otherwise, use an <input type="hidden">.
if (state.collection.size <= 300) {
return (
<div {...containerProps} data-testid="hidden-select-container">
<label>
{label}
<select {...selectProps} ref={selectRef}>
<option value="" label={'\u00A0'}>
{'\u00A0'}
</option>
{[...state.collection.getKeys()].map(key => {
let item = state.collection.getItem(key);
if (item && item.type === 'item') {
return (
<option key={item.key} value={item.key}>
{item.textValue}
</option>
);
}
})}
{/* The collection may be empty during the initial render. */}
{/* Rendering options for the current values ensures the select has a value immediately, */}
{/* making FormData reads consistent. */}
{state.collection.size === 0 &&
name &&
values.map((value, i) => <option key={i} value={value ?? ''} />)}
</select>
</label>
</div>
);
} else if (name) {
let data = selectData.get(state) || {};
let {validationBehavior} = data;

// Always render at least one hidden input to ensure required form submission.
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. */
}}
/>
);
}

return <input key={i} {...inputProps} ref={i === 0 ? inputRef : null} />;
});

return <>{res}</>;
}

return null;
return (
<div {...containerProps} data-testid="hidden-select-container">
<label>
{label}
<select {...selectProps} ref={selectRef}>
<option value="" label={'\u00A0'}>
{'\u00A0'}
</option>
{/* Avoid rendering a large native select while preserving form and change event semantics. */}
{state.collection.size <= 300
? [...state.collection.getKeys()].map(key => {
let item = state.collection.getItem(key);
if (item && item.type === 'item') {
return (
<option key={item.key} value={item.key}>
{item.textValue}
</option>
);
}
})
: values.map((value, i) => (value != null ? <option key={i} value={value} /> : null))}
{/* The collection may be empty during the initial render. */}
{/* Rendering options for the current values ensures the select has a value immediately, */}
{/* making FormData reads consistent. */}
{state.collection.size === 0 &&
name &&
values.map((value, i) => <option key={i} value={value ?? ''} />)}
</select>
</label>
</div>
);
}
17 changes: 17 additions & 0 deletions packages/react-aria/test/select/HiddenSelect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ describe('<HiddenSelect />', () => {
);
});

it('should only render selected options for collection.size > 300', () => {
render(
<HiddenSelectExample
label="select"
value={5}
hiddenProps={{
name: 'select'
}}
items={makeItems(400)}
/>
);

let select = screen.getByLabelText('select') as HTMLSelectElement;
expect(select).toHaveValue('5');
expect(select.options).toHaveLength(2);
});

it('should have form value after initial render', async () => {
let formRef = React.createRef<HTMLFormElement>();
render(
Expand Down
Loading