Skip to content

Commit

Permalink
Make FormContext match our other contexts (#6302)
Browse files Browse the repository at this point in the history
Co-authored-by: Daniel Lu <dl1644@gmail.com>
  • Loading branch information
devongovett and LFDanLu committed May 1, 2024
1 parent ec7c614 commit 313cc05
Show file tree
Hide file tree
Showing 11 changed files with 77 additions and 76 deletions.
94 changes: 47 additions & 47 deletions packages/react-aria-components/docs/Form.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -162,61 +162,25 @@ See the [Forms](forms.html) guide to learn more about form validation in React A

### Validation behavior

By default, native HTML form validation is used to display errors and block form submission.
By default, native HTML form validation is used to display errors and block form submission. To instead use ARIA attributes for form validation, set the `validationBehavior` prop to "aria". This will not block form submission, and will display validation errors to the user in realtime as the value is edited.

The `validationBehavior` can be set at the form level to apply to all fields, or at the field level to override the form's behavior for a specific field.

```tsx example
<Form>
<TextField name="email" type="email" isRequired>
<Label>Email</Label>
<Form validationBehavior="aria">
<TextField
name="username"
defaultValue="admin"
isRequired
validate={value => value === 'admin' ? 'Nice try.' : null}>
<Label>Username</Label>
<Input />
<FieldError />
</TextField>
<div style={{display: 'flex', gap: 8}}>
<Button type="submit">Submit</Button>
<Button type="reset">Reset</Button>
</div>
<Button type="submit">Submit</Button>
</Form>
```

To instead use ARIA attributes for form validation, set the validationBehavior prop to "aria". This will not block form submission, and will ensure validation errors are displayed to the user in realtime as the value is edited.

This can be set at the form level to apply to all fields, or at the field level to override the form's behavior for a specific field.

```tsx example
const EMAIL_REGEX = /^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/;

function Example() {
let [value, setValue] = React.useState('');
return (
<Form validationBehavior="aria" onSubmit={(e) => {e.preventDefault()}}>
<TextField
name="email"
type="email"
value={value}
onChange={setValue}
isRequired
isInvalid={value.length > 0 && !EMAIL_REGEX.test(value)}>
<Label>Email</Label>
<Input />
<FieldError />
</TextField>
<div style={{display: 'flex', gap: 8}}>
<Button type="submit">Submit</Button>
<Button onPress={() => setValue('')}>Reset</Button>
</div>
</Form>
);
}
```

The `validationBehavior` for a Form can be accessed from within any component inside a given From by using the `FormContext`.

```tsx
import {FormContext} from 'react-aria-components';

let {validationBehavior} = useContext(FormContext);
```

### Focus management

By default, after a user submits a form with validation errors, the first invalid field will be focused. You can prevent this by calling `preventDefault` during the `onInvalid` event, and move focus yourself. This example shows how to move focus to an [alert](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/alert_role) element at the top of a form.
Expand Down Expand Up @@ -318,6 +282,42 @@ A custom `className` can also be specified on any component. This overrides the

## Advanced customization

### Contexts

All React Aria Components export a corresponding context that can be used to send props to them from a parent element. This enables you to build your own compositional APIs similar to those found in React Aria Components itself. You can send any prop or ref via context that you could pass to the corresponding component. The local props and ref on the component are merged with the ones passed via context, with the local props taking precedence (following the rules documented in [mergeProps](mergeProps.html)).

<ContextTable components={['Form']} docs={docs} />

This example adds a global form submission handler for all forms rendered inside it, which could be used to centralize logic to submit data to an API.

```tsx
let onSubmit = e => {
e.preventDefault();
// Submit form data to an API...
};

<FormContext.Provider value={{onSubmit}}>
<Form>
{/* ... */}
</Form>
</FormContext.Provider>
```

`FormContext` can also be used within any component inside a form to access props from the nearest ancestor form. For example, to access the current `validationBehavior`, use the [useSlottedContext](advanced.html#useslottedcontext) hook.

```tsx
import {FormContext, useSlottedContext} from 'react-aria-components';

function MyFormField() {
let {validationBehavior} = useSlottedContext(FormContext);
// ...
}

<Form validationBehavior="aria">
<MyFormField />
</Form>
```

### Validation context

The `Form` component provides a value for `FormValidationContext`, which allows child elements to receive validation errors from the form. You can provide a value for this context directly in case you need to customize the form element, or reuse an existing form component.
Expand Down
6 changes: 3 additions & 3 deletions packages/react-aria-components/src/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import {AriaCheckboxGroupProps, AriaCheckboxProps, HoverEvents, mergeProps, useCheckbox, useCheckboxGroup, useCheckboxGroupItem, useFocusRing, useHover, VisuallyHidden} from 'react-aria';
import {CheckboxContext} from './RSPContexts';
import {CheckboxGroupState, useCheckboxGroupState, useToggleState} from 'react-stately';
import {ContextValue, forwardRefType, Provider, RACValidation, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils';
import {ContextValue, forwardRefType, Provider, RACValidation, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils';
import {FieldErrorContext} from './FieldError';
import {filterDOMProps, mergeRefs, useObjectRef} from '@react-aria/utils';
import {FormContext} from './Form';
Expand Down Expand Up @@ -113,7 +113,7 @@ export const CheckboxGroupStateContext = createContext<CheckboxGroupState | null

function CheckboxGroup(props: CheckboxGroupProps, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, CheckboxGroupContext);
let {validationBehavior: formValidationBehavior} = useContext(FormContext) || {};
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {};
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native';
let state = useCheckboxGroupState({
...props,
Expand Down Expand Up @@ -172,7 +172,7 @@ function Checkbox(props: CheckboxProps, ref: ForwardedRef<HTMLLabelElement>) {
...otherProps
} = props;
[props, ref] = useContextProps(otherProps, ref, CheckboxContext);
let {validationBehavior: formValidationBehavior} = useContext(FormContext) || {};
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {};
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native';
let groupState = useContext(CheckboxGroupStateContext);
let inputRef = useObjectRef(mergeRefs(userProvidedInputRef, props.inputRef !== undefined ? props.inputRef : null));
Expand Down
6 changes: 3 additions & 3 deletions packages/react-aria-components/src/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {AriaComboBoxProps, useComboBox, useFilter} from 'react-aria';
import {ButtonContext} from './Button';
import {Collection, ComboBoxState, Node, useComboBoxState} from 'react-stately';
import {CollectionDocumentContext, useCollectionDocument} from './Collection';
import {ContextValue, forwardRefType, Hidden, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils';
import {ContextValue, forwardRefType, Hidden, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils';
import {FieldErrorContext} from './FieldError';
import {filterDOMProps, useResizeObserver} from '@react-aria/utils';
import {FormContext} from './Form';
Expand All @@ -23,7 +23,7 @@ import {LabelContext} from './Label';
import {ListBoxContext, ListStateContext} from './ListBox';
import {OverlayTriggerStateContext} from './Dialog';
import {PopoverContext} from './Popover';
import React, {createContext, ForwardedRef, forwardRef, RefObject, useCallback, useContext, useMemo, useRef, useState} from 'react';
import React, {createContext, ForwardedRef, forwardRef, RefObject, useCallback, useMemo, useRef, useState} from 'react';
import {TextContext} from './Text';

export interface ComboBoxRenderProps {
Expand Down Expand Up @@ -115,7 +115,7 @@ function ComboBoxInner<T extends object>({props, collection, comboBoxRef: ref}:
formValue = 'text';
}

let {validationBehavior: formValidationBehavior} = useContext(FormContext) || {};
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {};
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native';
let {contains} = useFilter({sensitivity: 'base'});
let state = useComboBoxState({
Expand Down
6 changes: 3 additions & 3 deletions packages/react-aria-components/src/DateField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/
import {AriaDateFieldProps, AriaTimeFieldProps, DateValue, HoverEvents, mergeProps, TimeValue, useDateField, useDateSegment, useFocusRing, useHover, useLocale, useTimeField} from 'react-aria';
import {ContextValue, forwardRefType, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils';
import {ContextValue, forwardRefType, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils';
import {createCalendar} from '@internationalized/date';
import {DateFieldState, DateSegmentType, DateSegment as IDateSegment, TimeFieldState, useDateFieldState, useTimeFieldState} from 'react-stately';
import {FieldErrorContext} from './FieldError';
Expand Down Expand Up @@ -48,7 +48,7 @@ export const TimeFieldStateContext = createContext<TimeFieldState | null>(null);

function DateField<T extends DateValue>(props: DateFieldProps<T>, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, DateFieldContext);
let {validationBehavior: formValidationBehavior} = useContext(FormContext) || {};
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {};
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native';
let {locale} = useLocale();
let state = useDateFieldState({
Expand Down Expand Up @@ -115,7 +115,7 @@ export {_DateField as DateField};

function TimeField<T extends TimeValue>(props: TimeFieldProps<T>, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, TimeFieldContext);
let {validationBehavior: formValidationBehavior} = useContext(FormContext) || {};
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {};
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native';
let {locale} = useLocale();
let state = useTimeFieldState({
Expand Down
8 changes: 4 additions & 4 deletions packages/react-aria-components/src/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import {AriaDatePickerProps, AriaDateRangePickerProps, DateValue, useDatePicker, useDateRangePicker, useFocusRing} from 'react-aria';
import {ButtonContext} from './Button';
import {CalendarContext, RangeCalendarContext} from './Calendar';
import {ContextValue, forwardRefType, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils';
import {ContextValue, forwardRefType, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils';
import {DateFieldContext} from './DateField';
import {DatePickerState, DatePickerStateOptions, DateRangePickerState, DateRangePickerStateOptions, useDatePickerState, useDateRangePickerState} from 'react-stately';
import {DialogContext, OverlayTriggerStateContext} from './Dialog';
Expand All @@ -22,7 +22,7 @@ import {FormContext} from './Form';
import {GroupContext} from './Group';
import {LabelContext} from './Label';
import {PopoverContext} from './Popover';
import React, {createContext, ForwardedRef, forwardRef, useCallback, useContext, useRef, useState} from 'react';
import React, {createContext, ForwardedRef, forwardRef, useCallback, useRef, useState} from 'react';
import {TextContext} from './Text';

export interface DatePickerRenderProps {
Expand Down Expand Up @@ -73,7 +73,7 @@ export const DateRangePickerStateContext = createContext<DateRangePickerState |

function DatePicker<T extends DateValue>(props: DatePickerProps<T>, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, DatePickerContext);
let {validationBehavior: formValidationBehavior} = useContext(FormContext) || {};
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {};
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native';
let state = useDatePickerState({
...props,
Expand Down Expand Up @@ -176,7 +176,7 @@ export {_DatePicker as DatePicker};

function DateRangePicker<T extends DateValue>(props: DateRangePickerProps<T>, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, DateRangePickerContext);
let {validationBehavior: formValidationBehavior} = useContext(FormContext) || {};
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {};
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native';
let state = useDateRangePickerState({
...props,
Expand Down
7 changes: 4 additions & 3 deletions packages/react-aria-components/src/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {DOMProps} from './utils';
import {ContextValue, DOMProps, useContextProps} from './utils';
import {FormValidationContext} from 'react-stately';
import React, {createContext, ForwardedRef, forwardRef} from 'react';
import {FormProps as SharedFormProps} from '@react-types/form';
Expand All @@ -25,13 +25,14 @@ export interface FormProps extends SharedFormProps, DOMProps {
validationBehavior?: 'aria' | 'native'
}

export const FormContext = createContext<{validationBehavior: FormProps['validationBehavior']} | null>(null);
export const FormContext = createContext<ContextValue<FormProps, HTMLFormElement>>(null);

function Form(props: FormProps, ref: ForwardedRef<HTMLFormElement>) {
[props, ref] = useContextProps(props, ref, FormContext);
let {validationErrors, validationBehavior = 'native', children, className, ...domProps} = props;
return (
<form noValidate={validationBehavior !== 'native'} {...domProps} ref={ref} className={className || 'react-aria-Form'}>
<FormContext.Provider value={{validationBehavior}}>
<FormContext.Provider value={{...props, validationBehavior}}>
<FormValidationContext.Provider value={validationErrors ?? {}}>
{children}
</FormValidationContext.Provider>
Expand Down
6 changes: 3 additions & 3 deletions packages/react-aria-components/src/NumberField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {AriaNumberFieldProps, useLocale, useNumberField} from 'react-aria';
import {ButtonContext} from './Button';
import {ContextValue, forwardRefType, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils';
import {ContextValue, forwardRefType, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils';
import {FieldErrorContext} from './FieldError';
import {filterDOMProps} from '@react-aria/utils';
import {FormContext} from './Form';
Expand All @@ -21,7 +21,7 @@ import {InputContext} from './Input';
import {InputDOMProps} from '@react-types/shared';
import {LabelContext} from './Label';
import {NumberFieldState, useNumberFieldState} from 'react-stately';
import React, {createContext, ForwardedRef, forwardRef, useContext, useRef} from 'react';
import React, {createContext, ForwardedRef, forwardRef, useRef} from 'react';
import {TextContext} from './Text';

export interface NumberFieldRenderProps {
Expand All @@ -48,7 +48,7 @@ export const NumberFieldStateContext = createContext<NumberFieldState | null>(nu

function NumberField(props: NumberFieldProps, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, NumberFieldContext);
let {validationBehavior: formValidationBehavior} = useContext(FormContext) || {};
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {};
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native';
let {locale} = useLocale();
let state = useNumberFieldState({
Expand Down
6 changes: 3 additions & 3 deletions packages/react-aria-components/src/RadioGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@
*/

import {AriaRadioGroupProps, AriaRadioProps, HoverEvents, Orientation, useFocusRing, useHover, useRadio, useRadioGroup, VisuallyHidden} from 'react-aria';
import {ContextValue, forwardRefType, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils';
import {ContextValue, forwardRefType, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils';
import {FieldErrorContext} from './FieldError';
import {filterDOMProps, mergeProps, mergeRefs, useObjectRef} from '@react-aria/utils';
import {FormContext} from './Form';
import {LabelContext} from './Label';
import {RadioGroupState, useRadioGroupState} from 'react-stately';
import React, {createContext, ForwardedRef, forwardRef, MutableRefObject, useContext} from 'react';
import React, {createContext, ForwardedRef, forwardRef, MutableRefObject} from 'react';
import {TextContext} from './Text';

export interface RadioGroupProps extends Omit<AriaRadioGroupProps, 'children' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps<RadioGroupRenderProps>, SlotProps {}
Expand Down Expand Up @@ -114,7 +114,7 @@ export const RadioGroupStateContext = createContext<RadioGroupState | null>(null

function RadioGroup(props: RadioGroupProps, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, RadioGroupContext);
let {validationBehavior: formValidationBehavior} = useContext(FormContext) || {};
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {};
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native';
let state = useRadioGroupState({
...props,
Expand Down
6 changes: 3 additions & 3 deletions packages/react-aria-components/src/SearchField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@

import {AriaSearchFieldProps, useSearchField} from 'react-aria';
import {ButtonContext} from './Button';
import {ContextValue, forwardRefType, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils';
import {ContextValue, forwardRefType, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils';
import {FieldErrorContext} from './FieldError';
import {filterDOMProps} from '@react-aria/utils';
import {FormContext} from './Form';
import {GroupContext} from './Group';
import {InputContext} from './Input';
import {LabelContext} from './Label';
import React, {createContext, ForwardedRef, forwardRef, useContext, useRef} from 'react';
import React, {createContext, ForwardedRef, forwardRef, useRef} from 'react';
import {SearchFieldState, useSearchFieldState} from 'react-stately';
import {TextContext} from './Text';

Expand Down Expand Up @@ -51,7 +51,7 @@ export const SearchFieldContext = createContext<ContextValue<SearchFieldProps, H

function SearchField(props: SearchFieldProps, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, SearchFieldContext);
let {validationBehavior: formValidationBehavior} = useContext(FormContext) || {};
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {};
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native';
let inputRef = useRef<HTMLInputElement>(null);
let [labelRef, label] = useSlot();
Expand Down

1 comment on commit 313cc05

@rspbot
Copy link

@rspbot rspbot commented on 313cc05 May 1, 2024

Choose a reason for hiding this comment

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

Please sign in to comment.