Skip to content

Commit 849e52a

Browse files
fix: prevent form submission when required Select has more than 300 options and no selection (#8280)
* fix: prevent form submission when required Select has more than 300 options and no selection * fix: only block form submission if the validation behavior is native * fix: lint errors * fix: prevent invalid form control error and native error popup for visually hidden required input * fix: prevent visually hidden text input from affecting keyboard navigation * refactor: use display: none instead of visually hidden to hide the input * fix conflict where select would steal focus on invalid from previous invalid components --------- Co-authored-by: Robert Snow <[email protected]> Co-authored-by: Robert Snow <[email protected]>
1 parent 8f0ba04 commit 849e52a

File tree

2 files changed

+65
-12
lines changed

2 files changed

+65
-12
lines changed

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

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {FocusableElement, RefObject} from '@react-types/shared';
14-
import React, {JSX, ReactNode, useCallback, useRef} from 'react';
14+
import React, {InputHTMLAttributes, JSX, ReactNode, useCallback, useRef} from 'react';
1515
import {selectData} from './useSelect';
1616
import {SelectState} from '@react-stately/select';
1717
import {useFormReset} from '@react-aria/utils';
@@ -51,7 +51,7 @@ export interface HiddenSelectProps<T> extends AriaHiddenSelectProps {
5151

5252
export interface AriaHiddenSelectOptions extends AriaHiddenSelectProps {
5353
/** A ref to the hidden `<select>` element. */
54-
selectRef?: RefObject<HTMLSelectElement | null>
54+
selectRef?: RefObject<HTMLSelectElement | HTMLInputElement | null>
5555
}
5656

5757
export interface HiddenSelectAria {
@@ -124,7 +124,8 @@ export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: Select
124124
export function HiddenSelect<T>(props: HiddenSelectProps<T>): JSX.Element | null {
125125
let {state, triggerRef, label, name, form, isDisabled} = props;
126126
let selectRef = useRef(null);
127-
let {containerProps, selectProps} = useHiddenSelect({...props, selectRef}, state, triggerRef);
127+
let inputRef = useRef(null);
128+
let {containerProps, selectProps} = useHiddenSelect({...props, selectRef: state.collection.size <= 300 ? selectRef : inputRef}, state, triggerRef);
128129

129130
// If used in a <form>, use a hidden input so the value can be submitted to a server.
130131
// If the collection isn't too big, use a hidden <select> element for this so that browser
@@ -153,14 +154,34 @@ export function HiddenSelect<T>(props: HiddenSelectProps<T>): JSX.Element | null
153154
</div>
154155
);
155156
} else if (name) {
157+
let data = selectData.get(state) || {};
158+
let {validationBehavior} = data;
159+
160+
let inputProps: InputHTMLAttributes<HTMLInputElement> = {
161+
type: 'hidden',
162+
autoComplete: selectProps.autoComplete,
163+
name,
164+
form,
165+
disabled: isDisabled,
166+
value: state.selectedKey ?? ''
167+
};
168+
169+
if (validationBehavior === 'native') {
170+
// Use a hidden <input type="text"> rather than <input type="hidden">
171+
// so that an empty value blocks HTML form submission when the field is required.
172+
return (
173+
<input
174+
{...inputProps}
175+
ref={inputRef}
176+
style={{display: 'none'}}
177+
type="text"
178+
required={selectProps.required}
179+
onChange={() => {/** Ignore react warning. */}} />
180+
);
181+
}
182+
156183
return (
157-
<input
158-
type="hidden"
159-
autoComplete={selectProps.autoComplete}
160-
name={name}
161-
form={form}
162-
disabled={isDisabled}
163-
value={state.selectedKey ?? ''} />
184+
<input {...inputProps} ref={inputRef} />
164185
);
165186
}
166187

packages/react-aria-components/stories/Select.stories.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ import {UNSTABLE_ListBoxLoadingSentinel} from '../src/ListBox';
1818
import {useAsyncList} from 'react-stately';
1919

2020
export default {
21-
title: 'React Aria Components/Select'
21+
title: 'React Aria Components/Select',
22+
argTypes: {
23+
validationBehavior: {
24+
control: 'select',
25+
options: ['native', 'aria']
26+
}
27+
}
2228
};
2329

2430
export const SelectExample = () => (
@@ -64,7 +70,11 @@ export const SelectRenderProps = () => (
6470
</Select>
6571
);
6672

67-
let manyItems = [...Array(100)].map((_, i) => ({id: i, name: `Item ${i}`}));
73+
let makeItems = (length: number) => Array.from({length}, (_, i) => ({
74+
id: i,
75+
name: `Item ${i}`
76+
}));
77+
let manyItems = makeItems(100);
6878

6979
const usStateOptions = [
7080
{id: 'AL', name: 'Alabama'},
@@ -274,3 +284,25 @@ export const SelectSubmitExample = () => (
274284
<Button type="reset">Reset</Button>
275285
</Form>
276286
);
287+
288+
// Test case for https://github.com/adobe/react-spectrum/issues/8034
289+
// Required select validation cannot currently be tested in the jsdom environment.
290+
// In jsdom, forms are submitted even when required fields are empty.
291+
// See: https://github.com/jsdom/jsdom/issues/2898
292+
export const RequiredSelectWithManyItems = (props) => (
293+
<form>
294+
<Select {...props} name="select" isRequired>
295+
<Label style={{display: 'block'}}>Required Select with many items</Label>
296+
<Button>
297+
<SelectValue />
298+
<span aria-hidden="true" style={{paddingLeft: 5}}></span>
299+
</Button>
300+
<Popover>
301+
<ListBox items={makeItems(301)} className={styles.menu}>
302+
{item => <MyListBoxItem>{item.name}</MyListBoxItem>}
303+
</ListBox>
304+
</Popover>
305+
</Select>
306+
<Button type="submit">Submit</Button>
307+
</form>
308+
);

0 commit comments

Comments
 (0)