Skip to content

Support native form validation#5288

Merged
devongovett merged 42 commits into
mainfrom
validation
Oct 27, 2023
Merged

Support native form validation#5288
devongovett merged 42 commits into
mainfrom
validation

Conversation

@devongovett
Copy link
Copy Markdown
Member

Supersedes #4640

This implements support for native HTML form validation across all of our form components. It adds the following APIs:

  • The validationBehavior prop can be set to "aria" (the default) or "native". When set to "native", we use the native required attribute rather than aria-required so that form submission is blocked by the browser. In addition, we track other native validation messages such as pattern or type="email" and display all validation errors as Spectrum help text rather than the browser's builtin UI.
  • The validate prop can be set to a function to perform custom validation. This receives the current value of the field and can return an error message (or multiple) if it is invalid. This is displayed immediately (in realtime) if validationBehavior="aria", or after the field value is committed (e.g. blurred) when validationBehavior="native". This is in addition to our existing isInvalid and errorMessage props which are always displayed in realtime.
  • The errorMessage prop now accepts a function in addition to a string. The function receives the current validation details for the field and returns the validation error to display. This allows customizing native validation messages provided by the browser.
  • The Form component accepts a validationErrors prop, which can be set to an object mapping field name attributes to error messages. This is useful to display validation errors that come from the server. The fields receive these messages from the form via context. After the user modifies the value in the field, the server validation errors for that field are cleared until the validationErrors prop changes (i.e. after the next submit).

Implementation

Currently this is implemented in the hooks and in React Spectrum. It will come to React Aria Components in a followup PR. The implementation consists of two parts:

  • useFormValidationState in @react-stately/form, and used by all of our stately hooks. This tracks both the realtime validation state and validation state currently displayed to the user, and combines all of the errors from all of the above sources together. It includes methods to update and commit the validation state when the user changes the value.
  • useFormValidation in @react-aria/form, used by all of our aria hooks. This synchronizes the validation state with the native input element, and triggers state updates when various browser events occur.

Test instructions

See the "Native validation" and "Server validation" stories that have been added under "Form" for the new behavior, and smoke test other form/component stories.

# Conflicts:
#	packages/@react-aria/checkbox/package.json
#	packages/@react-aria/datepicker/package.json
#	packages/@react-aria/numberfield/package.json
#	packages/@react-aria/radio/package.json
#	packages/@react-aria/textfield/package.json
#	packages/@react-spectrum/color/package.json
#	packages/@react-spectrum/datepicker/package.json
#	packages/@react-spectrum/form/package.json
#	packages/@react-spectrum/numberfield/package.json
#	packages/@react-spectrum/searchfield/package.json
#	packages/@react-spectrum/textfield/package.json
#	packages/@react-stately/datepicker/src/useDateFieldState.ts
# Conflicts:
#	packages/@react-spectrum/form/src/Form.tsx
#	packages/@react-spectrum/searchfield/src/SearchField.tsx
Is this a breaking change??
@rspbot
Copy link
Copy Markdown

rspbot commented Oct 25, 2023

Copy link
Copy Markdown
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

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

One thing I noticed when testing the stories, currently looking at the other code changes

}

ServerValidation.story = {
parameters: {description: {data: 'This story is to test that server errors appear after submission, and are cleared when a field is modified.'}}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Noticed that the "Favorite Pet" radiogroup validation error after submission doesn't clear when the user modifies the selected radio option via keyboard arrows. It only gets cleared if the user clicks on a different radio option

To reproduce:

  1. go to https://reactspectrum.blob.core.windows.net/reactspectrum/36a3c4cbf1421edd7cb21466d4fdad61077d1ca4/storybook/index.html?path=/story/form--server-validation&providerSwitcher-express=false and hit submit
  2. Tab navigate to the "Favorite Pet" radio group and change the selected radio via the keyboard arrows. Note the error doesn't clear

Comment thread packages/@react-aria/form/src/useFormValidation.ts
Comment thread packages/@react-stately/utils/src/useControlledState.ts
Comment thread packages/@react-stately/form/src/useFormValidationState.ts
Comment thread packages/@react-spectrum/label/src/Field.tsx
// (FocusScope attempts to restore focus to the tray input when tapping outside the tray due to "contain")
// Have to do this manually since React doesn't call onBlur when a component is unmounted: https://github.com/facebook/react/issues/12363
return () => {
if (!state.isOpen && state.isFocused) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

comment above this line needs correcting, we do this before unmount now

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

hmm I already changed it to say "When the tray closes" instead of "unmounts". Do you mean the line about "React doesn't call onBlur when a component is unmounted"? I think that part is still true?

}, [fn]);
return useCallback((...args) => {
const f = ref.current;
// @ts-ignore
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i couldn't solve this one either :(

onChange = () => {}
validationBehavior = 'aria'
}: AriaTextFieldOptions<TextFieldIntrinsicElements> = props;
let [value, setValue] = useControlledState<string>(props.value, props.defaultValue || '', props.onChange);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this is a bit odd, i haven't seen useControlledState in an aria package before. I assume this is because textfield doesn't have a corresponding stately hook? should it? I guess that would be breaking...

Comment thread packages/@react-aria/select/src/HiddenSelect.tsx
Comment thread packages/@react-aria/datepicker/src/useDateField.ts
@rspbot
Copy link
Copy Markdown

rspbot commented Oct 26, 2023

LFDanLu
LFDanLu previously approved these changes Oct 27, 2023
snowystinger
snowystinger previously approved these changes Oct 27, 2023
@snowystinger snowystinger dismissed stale reviews from LFDanLu and themself via 93a422d October 27, 2023 00:53
@rspbot
Copy link
Copy Markdown

rspbot commented Oct 27, 2023

@rspbot
Copy link
Copy Markdown

rspbot commented Oct 27, 2023

@rspbot
Copy link
Copy Markdown

rspbot commented Oct 27, 2023

## API Changes

unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any', access: 'private' }
unknown top level export { type: 'any', access: 'private' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'identifier', name: 'Column' }
unknown top level export { type: 'identifier', name: 'Column' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown type { type: 'link' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }
unknown top level export { type: 'any' }

@react-aria/checkbox

CheckboxAria

 CheckboxAria {
   inputProps: InputHTMLAttributes<HTMLInputElement>
   isDisabled: boolean
-  isInvalid: boolean
   isPressed: boolean
   isReadOnly: boolean
   isSelected: boolean
 }

it changed:

  • useCheckbox

@react-aria/interactions

InteractOutsideProps

 InteractOutsideProps {
   isDisabled?: boolean
-  onInteractOutside?: (SyntheticEvent) => void
-  onInteractOutsideStart?: (SyntheticEvent) => void
+  onInteractOutside?: (PointerEvent) => void
+  onInteractOutsideStart?: (PointerEvent) => void
   ref: RefObject<Element>
 }

it changed:

  • useInteractOutside

@react-aria/tag

AriaTagGroupProps

 AriaTagGroupProps<T> {
+  errorMessage?: ReactNode
   onRemove?: (Set<Key>) => void
   selectionBehavior?: SelectionBehavior
 }

@react-aria/utils

isVirtualPointerEvent

-useEffectEvent {
-  fn: any
-  returnVal: undefined
-}
+

useFormReset

-
+useEffectEvent<T extends Function> {
+  fn: T
+  returnVal: undefined
+}

@react-spectrum/form

Form

 SpectrumFormProps {
   action?: string
   children: ReactElement<SpectrumLabelableProps> | Array<ReactElement<SpectrumLabelableProps>>
   encType?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'
   isDisabled?: boolean
   isEmphasized?: boolean
   isQuiet?: boolean
   isReadOnly?: boolean
   isRequired?: boolean
   method?: 'get' | 'post'
   onSubmit?: FormEventHandler
   target?: '_blank' | '_self' | '_parent' | '_top'
+  validationBehavior?: 'aria' | 'native' = 'aria'
+  validationErrors?: ValidationErrors
   validationState?: ValidationState = 'valid'
 }

@react-spectrum/label

Field

 HelpText {
   descriptionProps?: HTMLAttributes<HTMLElement>
+  errorMessage?: ReactNode
   errorMessageProps?: HTMLAttributes<HTMLElement>
 }

@react-stately/checkbox

CheckboxGroupState

 CheckboxGroupState {
   addValue: (string) => void
   isDisabled: boolean
   isInvalid: boolean
   isReadOnly: boolean
+  isRequired: boolean
   isSelected: (string) => boolean
   removeValue: (string) => void
+  setInvalid: (string, ValidationResult) => void
   setValue: (Array<string>) => void
   toggleValue: (string) => void
   value: readonly Array<string>
 }

it changed:

  • useCheckboxGroupState

@react-stately/radio

RadioGroupState

 RadioGroupState {
   isDisabled: boolean
   isInvalid: boolean
   isReadOnly: boolean
   isRequired: boolean
   lastFocusedValue: string | null
-  selectedValue: string | null | undefined
+  selectedValue: string | null
   setLastFocusedValue: (string) => void
   setSelectedValue: (string) => void
 }

it changed:

  • useRadioGroupState

@react-aria/form

useFormValidation

-
+useFormValidation<T> {
+  props: Validation<T>
+  state: FormValidationState
+  ref: RefObject<ValidatableElement>
+  returnVal: undefined
+}

@react-stately/form

FormValidationContext

-
+useFormValidationState<T> {
+  props: FormValidationProps<T>
+  returnVal: undefined
+}

useFormValidationState

-
+DEFAULT_VALIDATION_RESULT {
+  isInvalid: any
+  validationDetails: VALID_VALIDITY_STATE
+  validationErrors: any
+}

DEFAULT_VALIDATION_RESULT

changed by:

  • VALID_VALIDITY_STATE
-
+VALID_VALIDITY_STATE {
+  badInput: any
+  customError: any
+  patternMismatch: any
+  rangeOverflow: any
+  rangeUnderflow: any
+  stepMismatch: any
+  tooLong: any
+  tooShort: any
+  typeMismatch: any
+  valid: any
+  valueMissing: any
+}

VALID_VALIDITY_STATE

-
+mergeValidation {
+  results: Array<ValidationResult>
+  returnVal: undefined
+}

it changed:

  • DEFAULT_VALIDATION_RESULT

privateValidationStateProp

-
+FormValidationState {
+  commitValidation: () => void
+  displayValidation: ValidationResult
+  realtimeValidation: ValidationResult
+  resetValidation: () => void
+  updateValidation: (ValidationResult) => void
+}

@devongovett devongovett merged commit 068ecd1 into main Oct 27, 2023
@devongovett devongovett deleted the validation branch October 27, 2023 01:39
tabIndex = undefined;
}

let {name, descriptionId, errorMessageId, validationBehavior} = radioGroupData.get(state)!;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This seems to break when the useRadio and useRadioGroup hook are used in separate components.

<RadioGroup>
  <RadioGroupItem />
</RadioGroup>

I think this is because in this setup the item is rendered first and then the parent is rendered. This worked before because the undefined values weren't being destructured.

Copy link
Copy Markdown
Member

@snowystinger snowystinger Nov 22, 2023

Choose a reason for hiding this comment

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

This is actually a common misconception. Parents are rendered first, but children's effects run first. See https://stackoverflow.com/questions/53781632/whats-useeffect-execution-order-and-its-internal-clean-up-logic-when-requestani for some examples.

Can you reproduce this in a codesandbox? Because we have an example in our docs where the hooks are in two different components and it looks like what you've outlined, but it's working for us https://react-spectrum.adobe.com/react-aria/useRadioGroup.html#example

Are you sure you've passed the correct state object from radio group to each radio?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

hey, thanks for the quick response!

You are right, I wasn't passing the correct state object. In addition to the state from useRadioGroupState I was passing extra fields through the context and then destructuring them when accessing the context. So this wasn't working for me prior either, it just wasn't throwing an error haha. After updating to use the same state object throughout it's working 👍

Here's a codesandbox showing the error I was seeing.

Thanks again for the quick response!!

export type ValidationErrors = Record<string, ValidationError>;
export type ValidationFunction<T> = (value: T) => ValidationError | true | null | undefined;

export interface Validation<T> {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This was a breaking change for my application.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants