Skip to content

Commit 068ecd1

Browse files
authored
Support native form validation (#5288)
1 parent fc45733 commit 068ecd1

File tree

133 files changed

+5340
-444
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

133 files changed

+5340
-444
lines changed

packages/@react-aria/autocomplete/src/useSearchAutocomplete.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ import {AriaButtonProps} from '@react-types/button';
1414
import {AriaListBoxOptions} from '@react-aria/listbox';
1515
import {AriaSearchAutocompleteProps} from '@react-types/autocomplete';
1616
import {ComboBoxState} from '@react-stately/combobox';
17-
import {DOMAttributes, KeyboardDelegate} from '@react-types/shared';
17+
import {DOMAttributes, KeyboardDelegate, ValidationResult} from '@react-types/shared';
1818
import {InputHTMLAttributes, RefObject} from 'react';
1919
import {mergeProps} from '@react-aria/utils';
2020
import {useComboBox} from '@react-aria/combobox';
2121
import {useSearchField} from '@react-aria/searchfield';
2222

23-
export interface SearchAutocompleteAria<T> {
23+
export interface SearchAutocompleteAria<T> extends ValidationResult {
2424
/** Props for the label element. */
2525
labelProps: DOMAttributes,
2626
/** Props for the search input element. */
@@ -61,11 +61,16 @@ export function useSearchAutocomplete<T>(props: AriaSearchAutocompleteOptions<T>
6161
onSubmit = () => {},
6262
onClear,
6363
onKeyDown,
64-
onKeyUp
64+
onKeyUp,
65+
isInvalid,
66+
validationState,
67+
validationBehavior,
68+
isRequired,
69+
...otherProps
6570
} = props;
6671

67-
let {inputProps, clearButtonProps, descriptionProps, errorMessageProps} = useSearchField({
68-
...props,
72+
let {inputProps, clearButtonProps} = useSearchField({
73+
...otherProps,
6974
value: state.inputValue,
7075
onChange: state.setInputValue,
7176
autoComplete: 'off',
@@ -87,11 +92,11 @@ export function useSearchAutocomplete<T>(props: AriaSearchAutocompleteOptions<T>
8792
value: state.inputValue,
8893
setValue: state.setInputValue
8994
}, inputRef);
90-
9195

92-
let {listBoxProps, labelProps, inputProps: comboBoxInputProps} = useComboBox(
96+
97+
let {listBoxProps, labelProps, inputProps: comboBoxInputProps, ...validation} = useComboBox(
9398
{
94-
...props,
99+
...otherProps,
95100
keyboardDelegate,
96101
popoverRef,
97102
listBoxRef,
@@ -100,7 +105,12 @@ export function useSearchAutocomplete<T>(props: AriaSearchAutocompleteOptions<T>
100105
onFocusChange: undefined,
101106
onBlur: undefined,
102107
onKeyDown: undefined,
103-
onKeyUp: undefined
108+
onKeyUp: undefined,
109+
isInvalid,
110+
validationState,
111+
validationBehavior,
112+
isRequired,
113+
validate: undefined
104114
},
105115
state
106116
);
@@ -110,7 +120,6 @@ export function useSearchAutocomplete<T>(props: AriaSearchAutocompleteOptions<T>
110120
inputProps: mergeProps(inputProps, comboBoxInputProps),
111121
listBoxProps,
112122
clearButtonProps,
113-
descriptionProps,
114-
errorMessageProps
123+
...validation
115124
};
116125
}

packages/@react-aria/autocomplete/test/useSearchAutocomplete.test.js

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import React from 'react';
1616
import {renderHook} from '@react-spectrum/test-utils';
1717
import {useComboBoxState} from '@react-stately/combobox';
1818
import {useSearchAutocomplete} from '../';
19-
import {useSingleSelectListState} from '@react-stately/list';
2019

2120
describe('useSearchAutocomplete', function () {
2221
let preventDefault = jest.fn();
@@ -28,7 +27,7 @@ describe('useSearchAutocomplete', function () {
2827
});
2928

3029
let defaultProps = {items: [{id: 1, name: 'one'}], children: (props) => <Item>{props.name}</Item>};
31-
let {result} = renderHook(() => useSingleSelectListState(defaultProps));
30+
let {result} = renderHook(() => useComboBoxState(defaultProps));
3231
let mockLayout = new ListLayout({
3332
rowHeight: 40
3433
});
@@ -47,7 +46,7 @@ describe('useSearchAutocomplete', function () {
4746
layout: mockLayout
4847
};
4948

50-
let {result} = renderHook(() => useSearchAutocomplete(props, useSingleSelectListState(defaultProps)));
49+
let {result} = renderHook(() => useSearchAutocomplete(props, useComboBoxState(defaultProps)));
5150
let {inputProps, listBoxProps, labelProps} = result.current;
5251

5352
expect(labelProps.id).toBeTruthy();
@@ -69,10 +68,7 @@ describe('useSearchAutocomplete', function () {
6968
label: 'test label',
7069
popoverRef: React.createRef(),
7170
inputRef: {
72-
current: {
73-
contains: jest.fn(),
74-
focus: jest.fn()
75-
}
71+
current: document.createElement('input')
7672
},
7773
listBoxRef: React.createRef(),
7874
layout: mockLayout

packages/@react-aria/checkbox/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@
2222
"url": "https://github.com/adobe/react-spectrum"
2323
},
2424
"dependencies": {
25+
"@react-aria/form": "3.0.0-alpha.1",
2526
"@react-aria/label": "^3.7.2",
2627
"@react-aria/toggle": "^3.8.2",
2728
"@react-aria/utils": "^3.21.1",
2829
"@react-stately/checkbox": "^3.5.1",
30+
"@react-stately/form": "3.0.0-alpha.1",
2931
"@react-stately/toggle": "^3.6.3",
3032
"@react-types/checkbox": "^3.5.2",
3133
"@react-types/shared": "^3.21.0",

packages/@react-aria/checkbox/src/useCheckbox.ts

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,12 @@
1313
import {AriaCheckboxProps} from '@react-types/checkbox';
1414
import {InputHTMLAttributes, RefObject, useEffect} from 'react';
1515
import {ToggleState} from '@react-stately/toggle';
16+
import {useFormValidation} from '@react-aria/form';
17+
import {useFormValidationState} from '@react-stately/form';
1618
import {useToggle} from '@react-aria/toggle';
19+
import {ValidationResult} from '@react-types/shared';
1720

18-
export interface CheckboxAria {
21+
export interface CheckboxAria extends ValidationResult {
1922
/** Props for the input element. */
2023
inputProps: InputHTMLAttributes<HTMLInputElement>,
2124
/** Whether the checkbox is selected. */
@@ -25,9 +28,7 @@ export interface CheckboxAria {
2528
/** Whether the checkbox is disabled. */
2629
isDisabled: boolean,
2730
/** Whether the checkbox is read only. */
28-
isReadOnly: boolean,
29-
/** Whether the checkbox is invalid. */
30-
isInvalid: boolean
31+
isReadOnly: boolean
3132
}
3233

3334
/**
@@ -39,9 +40,17 @@ export interface CheckboxAria {
3940
* @param inputRef - A ref for the HTML input element.
4041
*/
4142
export function useCheckbox(props: AriaCheckboxProps, state: ToggleState, inputRef: RefObject<HTMLInputElement>): CheckboxAria {
42-
let {inputProps, isSelected, isPressed, isDisabled, isReadOnly, isInvalid} = useToggle(props, state, inputRef);
43+
// Create validation state here because it doesn't make sense to add to general useToggleState.
44+
let validationState = useFormValidationState({...props, value: state.isSelected});
45+
let {isInvalid, validationErrors, validationDetails} = validationState.displayValidation;
46+
let {inputProps, isSelected, isPressed, isDisabled, isReadOnly} = useToggle({
47+
...props,
48+
isInvalid
49+
}, state, inputRef);
50+
51+
useFormValidation(props, validationState, inputRef);
4352

44-
let {isIndeterminate} = props;
53+
let {isIndeterminate, isRequired, validationBehavior = 'aria'} = props;
4554
useEffect(() => {
4655
// indeterminate is a property, but it can only be set via javascript
4756
// https://css-tricks.com/indeterminate-checkboxes/
@@ -53,12 +62,16 @@ export function useCheckbox(props: AriaCheckboxProps, state: ToggleState, inputR
5362
return {
5463
inputProps: {
5564
...inputProps,
56-
checked: isSelected
65+
checked: isSelected,
66+
'aria-required': (isRequired && validationBehavior === 'aria') || undefined,
67+
required: isRequired && validationBehavior === 'native'
5768
},
5869
isSelected,
5970
isPressed,
6071
isDisabled,
6172
isReadOnly,
62-
isInvalid
73+
isInvalid,
74+
validationErrors,
75+
validationDetails
6376
};
6477
}

packages/@react-aria/checkbox/src/useCheckboxGroup.ts

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

1313
import {AriaCheckboxGroupProps} from '@react-types/checkbox';
14-
import {checkboxGroupDescriptionIds, checkboxGroupErrorMessageIds, checkboxGroupNames} from './utils';
14+
import {checkboxGroupData} from './utils';
1515
import {CheckboxGroupState} from '@react-stately/checkbox';
16-
import {DOMAttributes} from '@react-types/shared';
16+
import {DOMAttributes, ValidationResult} from '@react-types/shared';
1717
import {filterDOMProps, mergeProps} from '@react-aria/utils';
1818
import {useField} from '@react-aria/label';
1919

20-
export interface CheckboxGroupAria {
20+
export interface CheckboxGroupAria extends ValidationResult {
2121
/** Props for the checkbox group wrapper element. */
2222
groupProps: DOMAttributes,
2323
/** Props for the checkbox group's visible label (if any). */
@@ -35,21 +35,26 @@ export interface CheckboxGroupAria {
3535
* @param state - State for the checkbox group, as returned by `useCheckboxGroupState`.
3636
*/
3737
export function useCheckboxGroup(props: AriaCheckboxGroupProps, state: CheckboxGroupState): CheckboxGroupAria {
38-
let {isDisabled, name} = props;
38+
let {isDisabled, name, validationBehavior = 'aria'} = props;
39+
let {isInvalid, validationErrors, validationDetails} = state.displayValidation;
3940

4041
let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField({
4142
...props,
4243
// Checkbox group is not an HTML input element so it
4344
// shouldn't be labeled by a <label> element.
44-
labelElementType: 'span'
45+
labelElementType: 'span',
46+
isInvalid,
47+
errorMessage: props.errorMessage || validationErrors
4548
});
46-
checkboxGroupDescriptionIds.set(state, descriptionProps.id);
47-
checkboxGroupErrorMessageIds.set(state, errorMessageProps.id);
4849

49-
let domProps = filterDOMProps(props, {labelable: true});
50+
checkboxGroupData.set(state, {
51+
name,
52+
descriptionId: descriptionProps.id,
53+
errorMessageId: errorMessageProps.id,
54+
validationBehavior
55+
});
5056

51-
// Pass name prop from group to all items by attaching to the state.
52-
checkboxGroupNames.set(state, name);
57+
let domProps = filterDOMProps(props, {labelable: true});
5358

5459
return {
5560
groupProps: mergeProps(domProps, {
@@ -59,6 +64,9 @@ export function useCheckboxGroup(props: AriaCheckboxGroupProps, state: CheckboxG
5964
}),
6065
labelProps,
6166
descriptionProps,
62-
errorMessageProps
67+
errorMessageProps,
68+
isInvalid,
69+
validationErrors,
70+
validationDetails
6371
};
6472
}

packages/@react-aria/checkbox/src/useCheckboxGroupItem.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@
1212

1313
import {AriaCheckboxGroupItemProps} from '@react-types/checkbox';
1414
import {CheckboxAria, useCheckbox} from './useCheckbox';
15-
import {checkboxGroupDescriptionIds, checkboxGroupErrorMessageIds, checkboxGroupNames} from './utils';
15+
import {checkboxGroupData} from './utils';
1616
import {CheckboxGroupState} from '@react-stately/checkbox';
17-
import {RefObject} from 'react';
17+
import {DEFAULT_VALIDATION_RESULT, privateValidationStateProp, useFormValidationState} from '@react-stately/form';
18+
import {RefObject, useEffect, useRef} from 'react';
1819
import {useToggleState} from '@react-stately/toggle';
1920

2021
/**
@@ -41,11 +42,47 @@ export function useCheckboxGroupItem(props: AriaCheckboxGroupItemProps, state: C
4142
}
4243
});
4344

45+
let {name, descriptionId, errorMessageId, validationBehavior} = checkboxGroupData.get(state)!;
46+
validationBehavior = props.validationBehavior ?? validationBehavior;
47+
48+
// Local validation for this checkbox.
49+
let {realtimeValidation} = useFormValidationState({
50+
...props,
51+
value: toggleState.isSelected,
52+
// Server validation is handled at the group level.
53+
name: undefined,
54+
validationBehavior: 'aria'
55+
});
56+
57+
// Update the checkbox group state when realtime validation changes.
58+
let nativeValidation = useRef(DEFAULT_VALIDATION_RESULT);
59+
let updateValidation = () => {
60+
state.setInvalid(props.value, realtimeValidation.isInvalid ? realtimeValidation : nativeValidation.current);
61+
};
62+
63+
useEffect(updateValidation);
64+
65+
// Combine group and checkbox level validation.
66+
let combinedRealtimeValidation = state.realtimeValidation.isInvalid ? state.realtimeValidation : realtimeValidation;
67+
let displayValidation = validationBehavior === 'native' ? state.displayValidation : combinedRealtimeValidation;
68+
4469
let res = useCheckbox({
4570
...props,
4671
isReadOnly: props.isReadOnly || state.isReadOnly,
4772
isDisabled: props.isDisabled || state.isDisabled,
48-
name: props.name || checkboxGroupNames.get(state)
73+
name: props.name || name,
74+
isRequired: props.isRequired ?? state.isRequired,
75+
validationBehavior,
76+
[privateValidationStateProp]: {
77+
realtimeValidation: combinedRealtimeValidation,
78+
displayValidation,
79+
resetValidation: state.resetValidation,
80+
commitValidation: state.commitValidation,
81+
updateValidation(v) {
82+
nativeValidation.current = v;
83+
updateValidation();
84+
}
85+
}
4986
}, toggleState, inputRef);
5087

5188
return {
@@ -54,8 +91,8 @@ export function useCheckboxGroupItem(props: AriaCheckboxGroupItemProps, state: C
5491
...res.inputProps,
5592
'aria-describedby': [
5693
props['aria-describedby'],
57-
state.isInvalid ? checkboxGroupErrorMessageIds.get(state) : null,
58-
checkboxGroupDescriptionIds.get(state)
94+
state.isInvalid ? errorMessageId : null,
95+
descriptionId
5996
].filter(Boolean).join(' ') || undefined
6097
}
6198
};

packages/@react-aria/checkbox/src/utils.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212

1313
import {CheckboxGroupState} from '@react-stately/checkbox';
1414

15-
export const checkboxGroupNames = new WeakMap<CheckboxGroupState, string>();
16-
export const checkboxGroupDescriptionIds = new WeakMap<CheckboxGroupState, string>();
17-
export const checkboxGroupErrorMessageIds = new WeakMap<CheckboxGroupState, string>();
15+
interface CheckboxGroupData {
16+
name: string,
17+
descriptionId: string,
18+
errorMessageId: string,
19+
validationBehavior: 'aria' | 'native'
20+
}
21+
22+
export const checkboxGroupData = new WeakMap<CheckboxGroupState, CheckboxGroupData>();

packages/@react-aria/color/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@react-aria/utils": "^3.21.1",
3131
"@react-aria/visually-hidden": "^3.8.6",
3232
"@react-stately/color": "^3.4.4",
33+
"@react-stately/form": "3.0.0-alpha.1",
3334
"@react-types/color": "3.0.0-beta.20",
3435
"@react-types/shared": "^3.21.0",
3536
"@react-types/slider": "^3.6.2",

0 commit comments

Comments
 (0)