Skip to content

Improve HTML form integration#4640

Closed
devongovett wants to merge 16 commits into
mainfrom
form-integration
Closed

Improve HTML form integration#4640
devongovett wants to merge 16 commits into
mainfrom
form-integration

Conversation

@devongovett
Copy link
Copy Markdown
Member

@devongovett devongovett commented Jun 6, 2023

Closes #1690, closes #2825

This improves HTML form integration across all of our field components. It allows you to implement custom UI for native browser validation rather than implementing custom validation in JS. This is often a lot easier than dealing with controlled form state. It also improves integration with upcoming React features like server actions.

  • Adds support for native form reset, e.g. <Button type="reset">. This is implemented by subscribing to the reset event on the input's parent form, and triggering an onChange with the initially rendered value. Works with both uncontrolled and controlled inputs.
  • Adds support for submitting the numeric value in NumberField rather than the formatted text. Previously the name prop wasn't passed through at all, so this is not a breaking change. It is implemented by rendering an extra <input type="hidden">.
  • Adds support for name prop to slider and color components.
  • Adds support for submitting a stringified version of the date value in all date picker components, using an <input type="hidden">.
  • Adds support for submitting the key rather than the text value of a ComboBox, also using an <input type="hidden">. This can be controlled by setting the formValue prop. When allowsCustomValue is true, we always submit the text value.
  • Adds a validationBehavior prop, which can be set to either "aria" or "native". When set to "native", we use the HTML required prop rather than aria-required, which blocks form submission.
  • Adds validationState, errorMessage, and validationDetails as returned properties from all field aria hooks. When validationBehavior="native", we handle the native input "invalid" event and prevent default on it so that the browser's default form validation UI does not appear. Then we return the validation state and native error message from the hook so it can be rendered in a custom UI. In Spectrum, this is implemented as help text.
  • When validationBehavior="native", and an explicit validationState="invalid" prop is set, this state is also set on the underlying native input element (via setCustomValidity) so that form submission is blocked.
  • The onValidationChange event is also added to allow implementing more custom behavior when fields become valid or invalid.
  • In RAC, the <FormError> component is added, which allows you to use render props to render a custom form error message based on validation details from the input. We also set validationBehavior="native" there by default. In our hooks and spectrum this would be a breaking change so you must opt-in.

To do

  • Add docs
  • Discuss API

@rspbot
Copy link
Copy Markdown

rspbot commented Jun 6, 2023

@rspbot
Copy link
Copy Markdown

rspbot commented Jun 6, 2023

## API Changes

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' }

@react-aria/checkbox

AriaCheckboxGroupItemProps

 AriaCheckboxGroupItemProps {
   aria-controls?: string
   children?: ReactNode
   isIndeterminate?: boolean
-  name?: string
   onChange?: (boolean) => void
   value: string
 }

AriaCheckboxGroupProps

 AriaCheckboxGroupProps {
-  name?: string
+
 }

AriaCheckboxProps

 AriaCheckboxProps {
   aria-controls?: string
   children?: ReactNode
   defaultSelected?: boolean
   isIndeterminate?: boolean
   isSelected?: boolean
-  name?: string
   onChange?: (boolean) => void
   value?: string
 }

@react-aria/combobox

AriaComboBoxProps

 AriaComboBoxProps<T> {
   allowsCustomValue?: boolean
   defaultInputValue?: string
   defaultItems?: Iterable<T>
   inputValue?: string
   items?: Iterable<T>
   menuTrigger?: MenuTriggerAction = 'input'
-  name?: string
   onInputChange?: (string) => void
   onOpenChange?: (boolean, MenuTriggerAction) => void
   shouldFocusWrap?: boolean
 }

@react-aria/datepicker

useTimeField

changed by:

  • DateFieldAria
 useTimeField<T extends TimeValue> {
-  props: AriaTimeFieldProps<T>
-  state: DateFieldState
+  props: AriaTimeFieldOptions<T>
+  state: TimeFieldState
   ref: RefObject<Element>
   returnVal: undefined
 }

AriaDateRangePickerProps

 AriaDateRangePickerProps<T extends DateValue> {
   allowsNonContiguousRanges?: boolean
+  endName?: string
   granularity?: Granularity
   hideTimeZone?: boolean = false
   hourCycle?: number | number
   isDateUnavailable?: (DateValue) => boolean
   maxValue?: DateValue
   minValue?: DateValue
   placeholderValue?: DateValue
+  startName?: string
 }

AriaDateFieldOptions

 AriaDateFieldOptions<T extends DateValue> {
-
+  inputRef?: RefObject<HTMLInputElement>
 }

it changed:

  • useDateField

DateFieldAria

 DateFieldAria {
   descriptionProps: DOMAttributes
   errorMessageProps: DOMAttributes
   fieldProps: DOMAttributes
+  inputProps: InputHTMLAttributes<HTMLInputElement>
   labelProps: DOMAttributes
 }

it changed:

  • useDateField
  • useTimeField

@react-aria/radio

AriaRadioGroupProps

 AriaRadioGroupProps {
-  name?: string
   orientation?: Orientation = 'vertical'
 }

RadioAria

 RadioAria {
   inputProps: InputHTMLAttributes<HTMLInputElement>
   isDisabled: boolean
   isPressed: boolean
   isSelected: boolean
+  validationState: ValidationState
 }

it changed:

  • useRadio

@react-aria/select

useHiddenSelect

 useHiddenSelect<T> {
-  props: AriaHiddenSelectProps
+  props: AriaHiddenSelectOptions
   state: SelectState<T>
   triggerRef: RefObject<FocusableElement>
   returnVal: undefined
 }

AriaSelectOptions

 AriaSelectOptions<T> {
+  hiddenSelectRef?: RefObject<HTMLSelectElement>
   keyboardDelegate?: KeyboardDelegate
 }

it changed:

  • useSelect

@react-aria/switch

AriaSwitchProps

 AriaSwitchProps {
   aria-controls?: string
   children?: ReactNode
   defaultSelected?: boolean
   isSelected?: boolean
-  name?: string
   onChange?: (boolean) => void
   value?: string
 }

@react-aria/toggle

AriaToggleProps

 AriaToggleProps {
   aria-controls?: string
   children?: ReactNode
   defaultSelected?: boolean
   isSelected?: boolean
-  name?: string
   onChange?: (boolean) => void
   value?: string
 }

@react-aria/utils

useDeepMemo

-
+useFormReset<T> {
+  ref: RefObject<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
+  initialValue: T
+  onReset: (T) => void
+  returnVal: undefined
+}

useFormReset

-
+useFormValidation {
+  ref: RefObject<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
+  validationState: ValidationState
+  errorMessage: ReactNode
+  validationBehavior: 'native' | 'aria'
+  onValidationChange?: (FormValidationEvent) => void
+  returnVal: undefined
+}

useFormValidation

-
+useFormValidationState {
+  validationState: ValidationState
+  errorMessage: ReactNode
+  returnVal: undefined
+}

useFormValidationState

-
+mergeValidity {
+  a: ValidityState
+  b: ValidityState
+  returnVal: undefined
+}

mergeValidity

-
+FormValidationResult {
+  errorMessage: ReactNode
+  validationDetails: ValidityState
+  validationState: ValidationState
+}

@react-spectrum/checkbox

Checkbox

 SpectrumCheckboxProps {
   aria-controls?: string
   children?: ReactNode
   defaultSelected?: boolean
   isEmphasized?: boolean
   isIndeterminate?: boolean
   isSelected?: boolean
-  name?: string
   onChange?: (boolean) => void
   value?: string
 }

CheckboxGroup

 SpectrumCheckboxGroupProps {
   children: ReactElement<CheckboxProps> | Array<ReactElement<CheckboxProps>>
   isEmphasized?: boolean
-  name?: string
   orientation?: Orientation = 'vertical'
 }

@react-spectrum/color

ColorArea

 SpectrumColorAreaProps {
   isDisabled?: boolean
   onChange?: (Color) => void
   onChangeEnd?: (Color) => void
   size?: DimensionValue
   xChannel?: ColorChannel
+  xName?: string
   yChannel?: ColorChannel
+  yName?: string
 }

@react-spectrum/combobox

Section

 SpectrumComboBoxProps<T> {
   allowsCustomValue?: boolean
   defaultInputValue?: string
   defaultItems?: Iterable<T>
   direction?: 'bottom' | 'top' = 'bottom'
+  formValue?: 'text' | 'key' = 'text'
   inputValue?: string
   isQuiet?: boolean
   items?: Iterable<T>
   loadingState?: LoadingState
   menuTrigger?: MenuTriggerAction = 'input'
-  name?: string
   onInputChange?: (string) => void
   onOpenChange?: (boolean, MenuTriggerAction) => void
   shouldFlip?: boolean = true
   shouldFocusWrap?: boolean
 

@react-spectrum/datepicker

TimeField

 SpectrumDateRangePickerProps<T extends DateValue> {
   allowsNonContiguousRanges?: boolean
+  endName?: string
   granularity?: Granularity
   hideTimeZone?: boolean = false
   hourCycle?: number | number
   isDateUnavailable?: (DateValue) => boolean
   isQuiet?: boolean = false
   maxValue?: DateValue
   maxVisibleMonths?: number = 1
   minValue?: DateValue
   placeholderValue?: DateValue
   shouldFlip?: boolean = true
   showFormatHelpText?: boolean = false
+  startName?: string
 }

@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'
   validationState?: ValidationState = 'valid'
 }

@react-spectrum/radio

RadioGroup

 SpectrumRadioGroupProps {
   children: ReactElement<RadioProps> | Array<ReactElement<RadioProps>>
   isEmphasized?: boolean
-  name?: string
   orientation?: Orientation = 'vertical'
 }

@react-spectrum/slider

Slider

 SpectrumRangeSliderProps {
   contextualHelp?: ReactNode
+  endName?: string
   formatOptions?: Intl.NumberFormatOptions
   getValueLabel?: (RangeValue<number>) => string
   isDisabled?: boolean
   labelPosition?: LabelPosition = 'top'
   maxValue?: number = 100
   minValue?: number = 0
   onChangeEnd?: (RangeValue<number>) => void
   orientation?: Orientation = 'horizontal'
   showValueLabel?: boolean
+  startName?: string
   step?: number = 1
 }

@react-spectrum/switch

Switch

 SpectrumSwitchProps {
   aria-controls?: string
   children?: ReactNode
   defaultSelected?: boolean
   isEmphasized?: boolean
   isSelected?: boolean
-  name?: string
   onChange?: (boolean) => void
   value?: string
 }

@react-stately/datepicker

TimeFieldState

-
+TimeFieldState {
+  timeValue: Time
+}

it changed:

  • useTimeFieldState

@react-stately/numberfield

NumberFieldState

 NumberFieldState {
   canDecrement: boolean
   canIncrement: boolean
   commit: () => void
   decrement: () => void
   decrementToMin: () => void
   increment: () => void
   incrementToMax: () => void
   inputValue: string
   maxValue: number
   minValue: number
   numberValue: number
   setInputValue: (string) => void
+  setNumberValue: (number) => void
   validate: (string) => boolean
 }

it changed:

  • useNumberFieldState

@react-stately/radio

RadioGroupProps

 RadioGroupProps {
-  name?: string
   orientation?: Orientation = 'vertical'
 }

@react-stately/toggle

ToggleProps

 ToggleProps {
   children?: ReactNode
   defaultSelected?: boolean
   isSelected?: boolean
-  name?: string
   onChange?: (boolean) => void
   value?: string
 }

* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
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.

I put this in @react-aria/utils for now. Wondering if we should make a new package like @react-aria/form for it though. I thought utils since it wasn't really meant as public API, but there is a lot here. 🤷

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.

yes, i think it should be in a form package, even if it's not public right now, we may want it to be eventually. we do that in other places, we just don't document the things in it and we don't mention the package on our docs website.
If we wanted, we could start putting in the Readme that these are intended as private as well

@romansndlr
Copy link
Copy Markdown
Contributor

Apologies if this is rude of me, but can I ask why this PR is still in draft? Is there any work missing to get this merged? And if so, could I maybe help with anything?

@devongovett
Copy link
Copy Markdown
Member Author

@romansndlr it is sorta experimental at the moment, and needs a bunch more API refinement. For example I was working on a way to also support server side validation errors along with client side ones. After we figure that out the docs need to be written and we need to test everything out in real scenarios. I'd say it's a little ways off unfortunately. Glad it's interesting to you though!

@romansndlr
Copy link
Copy Markdown
Contributor

@devongovett That's awesome man and the work y'all are doing here is unbelievable! I was wondering if maybe there is a way to move forward with just the native HTML form integration part? We are working on a Remix app and I currently manage the "name" prop myself for the combo-box and other components as well. I worry that our own implementation would behave differently than the one y'all would rollout eventually.

@devongovett
Copy link
Copy Markdown
Member Author

@romansndlr I extracted the name and form reset stuff from this in #4795 so it can be released separately. Will continue working on the validation stuff separately.

@romansndlr
Copy link
Copy Markdown
Contributor

romansndlr commented Aug 3, 2023

@romansndlr I extracted the name and form reset stuff from this in #4795 so it can be released separately. Will continue working on the validation stuff separately.

That is amazing dude!! Thank you so much 🙏🏻

@waldothedeveloper
Copy link
Copy Markdown

@devongovett Thank you for the amazing work y'all doing, I love react-spectrum! I'm using useNumberField and I need the name prop, you mentioned here: #4640. that ->

It is implemented by rendering an extra <input type="hidden">.

Do you mind showing me a very simple example of how and where do I put this extra hidden input and what props or properties should have to make the name property work?

Thank you :)

@snowystinger
Copy link
Copy Markdown
Member

A good place to go for examples is our React Aria Components
Here is that line https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/NumberField.tsx#L115

If you can, we suggest using RAC because we'll handle putting all the requisite hooks together for you but still leave the dom up to you.

@waldothedeveloper
Copy link
Copy Markdown

A good place to go for examples is our React Aria Components Here is that line https://github.com/adobe/react-spectrum/blob/main/packages/react-aria-components/src/NumberField.tsx#L115

If you can, we suggest using RAC because we'll handle putting all the requisite hooks together for you but still leave the dom up to you.

Thank you so much Robert for the quick reply! Really appreciate the example, I'll incorporate the RAC later on :)

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.

Buttons with type="reset" don't reset the Form properly ComboBox in form submits input value rather than selected key

5 participants