From a19f04d14bf89062adb4dea6a64994d41ab4752d Mon Sep 17 00:00:00 2001
From: Devon Govett <devongovett@gmail.com>
Date: Wed, 18 Jun 2025 11:48:41 -0700
Subject: [PATCH 1/2] feat: Support associating components with external forms

---
 packages/@react-aria/button/src/useButton.ts  | 10 ++++-
 .../checkbox/src/useCheckboxGroup.ts          |  3 +-
 .../checkbox/src/useCheckboxGroupItem.ts      |  3 +-
 packages/@react-aria/checkbox/src/utils.ts    |  1 +
 .../@react-aria/color/src/useColorArea.ts     |  5 ++-
 .../@react-aria/color/src/useColorSlider.ts   |  3 +-
 .../@react-aria/color/src/useColorWheel.ts    |  4 +-
 .../datepicker/src/useDateField.ts            |  1 +
 .../datepicker/src/useDatePicker.ts           |  3 +-
 .../datepicker/src/useDateRangePicker.ts      |  2 +
 .../numberfield/src/useNumberField.ts         |  2 +
 packages/@react-aria/radio/src/useRadio.ts    |  3 +-
 .../@react-aria/radio/src/useRadioGroup.ts    |  2 +
 packages/@react-aria/radio/src/utils.ts       |  1 +
 .../@react-aria/select/src/HiddenSelect.tsx   | 13 +++++-
 packages/@react-aria/select/src/useSelect.ts  |  3 ++
 .../@react-aria/slider/src/useSliderThumb.ts  |  4 +-
 .../@react-aria/textfield/src/useTextField.ts |  1 +
 packages/@react-aria/toggle/src/useToggle.ts  |  2 +
 .../numberfield/src/NumberField.tsx           |  2 +-
 .../numberfield/test/NumberField.test.js      |  3 +-
 .../@react-spectrum/picker/src/Picker.tsx     |  4 +-
 .../picker/test/Picker.test.js                | 15 +++++++
 .../@react-spectrum/s2/src/RangeSlider.tsx    | 10 ++++-
 packages/@react-spectrum/s2/src/Slider.tsx    |  2 +-
 .../slider/src/RangeSlider.tsx                |  6 ++-
 .../@react-spectrum/slider/src/Slider.tsx     |  3 +-
 .../slider/test/RangeSlider.test.tsx          |  4 +-
 .../slider/test/Slider.test.tsx               |  3 +-
 packages/@react-types/button/src/index.d.ts   | 25 ++++++++++-
 packages/@react-types/color/src/index.d.ts    |  8 +++-
 .../@react-types/datepicker/src/index.d.ts    |  8 +++-
 packages/@react-types/select/src/index.d.ts   |  8 +++-
 packages/@react-types/shared/src/dom.d.ts     |  8 +++-
 packages/@react-types/slider/src/index.d.ts   |  8 +++-
 packages/react-aria-components/src/Button.tsx | 26 +-----------
 .../react-aria-components/src/NumberField.tsx |  2 +-
 packages/react-aria-components/src/Select.tsx |  1 +
 .../test/Checkbox.test.js                     |  6 +++
 .../test/CheckboxGroup.test.js                |  7 ++++
 .../test/ColorArea.test.js                    |  6 +++
 .../test/ColorField.test.js                   |  9 ++++
 .../test/ColorSlider.test.js                  |  6 +++
 .../test/ColorWheel.test.js                   |  6 +++
 .../test/ComboBox.test.js                     |  6 +++
 .../test/DateField.test.js                    |  3 +-
 .../test/DatePicker.test.js                   |  3 +-
 .../test/DateRangePicker.test.js              |  4 +-
 .../test/NumberField.test.js                  |  5 ++-
 .../test/RadioGroup.test.js                   |  7 ++++
 .../test/SearchField.test.js                  |  9 ++++
 .../react-aria-components/test/Select.test.js |  9 ++++
 .../react-aria-components/test/Slider.test.js | 41 +++++++++++--------
 .../react-aria-components/test/Switch.test.js |  6 +++
 .../test/TextField.test.js                    |  9 ++++
 .../test/TimeField.test.js                    |  3 +-
 56 files changed, 281 insertions(+), 76 deletions(-)

diff --git a/packages/@react-aria/button/src/useButton.ts b/packages/@react-aria/button/src/useButton.ts
index b4fae6cb224..8b33496778c 100644
--- a/packages/@react-aria/button/src/useButton.ts
+++ b/packages/@react-aria/button/src/useButton.ts
@@ -67,7 +67,15 @@ export function useButton(props: AriaButtonOptions<ElementType>, ref: RefObject<
   if (elementType === 'button') {
     additionalProps = {
       type,
-      disabled: isDisabled
+      disabled: isDisabled,
+      form: props.form,
+      formAction: props.formAction,
+      formEncType: props.formEncType,
+      formMethod: props.formMethod,
+      formNoValidate: props.formNoValidate,
+      formTarget: props.formTarget,
+      name: props.name,
+      value: props.value
     };
   } else {
     additionalProps = {
diff --git a/packages/@react-aria/checkbox/src/useCheckboxGroup.ts b/packages/@react-aria/checkbox/src/useCheckboxGroup.ts
index d35987aca4c..0056b9978ba 100644
--- a/packages/@react-aria/checkbox/src/useCheckboxGroup.ts
+++ b/packages/@react-aria/checkbox/src/useCheckboxGroup.ts
@@ -36,7 +36,7 @@ export interface CheckboxGroupAria extends ValidationResult {
  * @param state - State for the checkbox group, as returned by `useCheckboxGroupState`.
  */
 export function useCheckboxGroup(props: AriaCheckboxGroupProps, state: CheckboxGroupState): CheckboxGroupAria {
-  let {isDisabled, name, validationBehavior = 'aria'} = props;
+  let {isDisabled, name, form, validationBehavior = 'aria'} = props;
   let {isInvalid, validationErrors, validationDetails} = state.displayValidation;
 
   let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField({
@@ -50,6 +50,7 @@ export function useCheckboxGroup(props: AriaCheckboxGroupProps, state: CheckboxG
 
   checkboxGroupData.set(state, {
     name,
+    form,
     descriptionId: descriptionProps.id,
     errorMessageId: errorMessageProps.id,
     validationBehavior
diff --git a/packages/@react-aria/checkbox/src/useCheckboxGroupItem.ts b/packages/@react-aria/checkbox/src/useCheckboxGroupItem.ts
index 41a66b79a7d..758abcfb98a 100644
--- a/packages/@react-aria/checkbox/src/useCheckboxGroupItem.ts
+++ b/packages/@react-aria/checkbox/src/useCheckboxGroupItem.ts
@@ -43,7 +43,7 @@ export function useCheckboxGroupItem(props: AriaCheckboxGroupItemProps, state: C
     }
   });
 
-  let {name, descriptionId, errorMessageId, validationBehavior} = checkboxGroupData.get(state)!;
+  let {name, form, descriptionId, errorMessageId, validationBehavior} = checkboxGroupData.get(state)!;
   validationBehavior = props.validationBehavior ?? validationBehavior;
 
   // Local validation for this checkbox.
@@ -72,6 +72,7 @@ export function useCheckboxGroupItem(props: AriaCheckboxGroupItemProps, state: C
     isReadOnly: props.isReadOnly || state.isReadOnly,
     isDisabled: props.isDisabled || state.isDisabled,
     name: props.name || name,
+    form: props.form || form,
     isRequired: props.isRequired ?? state.isRequired,
     validationBehavior,
     [privateValidationStateProp]: {
diff --git a/packages/@react-aria/checkbox/src/utils.ts b/packages/@react-aria/checkbox/src/utils.ts
index 03a13138a63..78bead0bee7 100644
--- a/packages/@react-aria/checkbox/src/utils.ts
+++ b/packages/@react-aria/checkbox/src/utils.ts
@@ -14,6 +14,7 @@ import {CheckboxGroupState} from '@react-stately/checkbox';
 
 interface CheckboxGroupData {
   name?: string,
+  form?: string,
   descriptionId?: string,
   errorMessageId?: string,
   validationBehavior: 'aria' | 'native'
diff --git a/packages/@react-aria/color/src/useColorArea.ts b/packages/@react-aria/color/src/useColorArea.ts
index 2d46099cc7f..16b859fcf31 100644
--- a/packages/@react-aria/color/src/useColorArea.ts
+++ b/packages/@react-aria/color/src/useColorArea.ts
@@ -54,7 +54,8 @@ export function useColorArea(props: AriaColorAreaOptions, state: ColorAreaState)
     containerRef,
     'aria-label': ariaLabel,
     xName,
-    yName
+    yName,
+    form
   } = props;
   let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/color');
 
@@ -431,6 +432,7 @@ export function useColorArea(props: AriaColorAreaOptions, state: ColorAreaState)
       disabled: isDisabled,
       value: state.value.getChannelValue(xChannel),
       name: xName,
+      form,
       tabIndex: (isMobile || !focusedInput || focusedInput === 'x' ? undefined : -1),
       /*
         So that only a single "2d slider" control shows up when listing form elements for screen readers,
@@ -456,6 +458,7 @@ export function useColorArea(props: AriaColorAreaOptions, state: ColorAreaState)
       disabled: isDisabled,
       value: state.value.getChannelValue(yChannel),
       name: yName,
+      form,
       tabIndex: (isMobile || focusedInput === 'y' ? undefined : -1),
       /*
         So that only a single "2d slider" control shows up when listing form elements for screen readers,
diff --git a/packages/@react-aria/color/src/useColorSlider.ts b/packages/@react-aria/color/src/useColorSlider.ts
index 22e5c50cfc6..804914a983f 100644
--- a/packages/@react-aria/color/src/useColorSlider.ts
+++ b/packages/@react-aria/color/src/useColorSlider.ts
@@ -44,7 +44,7 @@ export interface ColorSliderAria {
  * Color sliders allow users to adjust an individual channel of a color value.
  */
 export function useColorSlider(props: AriaColorSliderOptions, state: ColorSliderState): ColorSliderAria {
-  let {trackRef, inputRef, orientation, channel, 'aria-label': ariaLabel, name} = props;
+  let {trackRef, inputRef, orientation, channel, 'aria-label': ariaLabel, name, form} = props;
 
   let {locale, direction} = useLocale();
 
@@ -60,6 +60,7 @@ export function useColorSlider(props: AriaColorSliderOptions, state: ColorSlider
     orientation,
     isDisabled: props.isDisabled,
     name,
+    form,
     trackRef,
     inputRef
   }, state);
diff --git a/packages/@react-aria/color/src/useColorWheel.ts b/packages/@react-aria/color/src/useColorWheel.ts
index d645f4fb06a..039c464abf0 100644
--- a/packages/@react-aria/color/src/useColorWheel.ts
+++ b/packages/@react-aria/color/src/useColorWheel.ts
@@ -45,7 +45,8 @@ export function useColorWheel(props: AriaColorWheelOptions, state: ColorWheelSta
     innerRadius,
     outerRadius,
     'aria-label': ariaLabel,
-    name
+    name,
+    form
   } = props;
 
   let {addGlobalListener, removeGlobalListener} = useGlobalListeners();
@@ -325,6 +326,7 @@ export function useColorWheel(props: AriaColorWheelOptions, state: ColorWheelSta
         disabled: isDisabled,
         value: `${state.value.getChannelValue('hue')}`,
         name,
+        form,
         onChange: (e: ChangeEvent<HTMLInputElement>) => {
           state.setHue(parseFloat(e.target.value));
         },
diff --git a/packages/@react-aria/datepicker/src/useDateField.ts b/packages/@react-aria/datepicker/src/useDateField.ts
index cf4e64216f4..0fbcbd68885 100644
--- a/packages/@react-aria/datepicker/src/useDateField.ts
+++ b/packages/@react-aria/datepicker/src/useDateField.ts
@@ -149,6 +149,7 @@ export function useDateField<T extends DateValue>(props: AriaDateFieldOptions<T>
   let inputProps: InputHTMLAttributes<HTMLInputElement> = {
     type: 'hidden',
     name: props.name,
+    form: props.form,
     value: state.value?.toString() || '',
     disabled: props.isDisabled
   };
diff --git a/packages/@react-aria/datepicker/src/useDatePicker.ts b/packages/@react-aria/datepicker/src/useDatePicker.ts
index 70980d1e17e..571c53fd179 100644
--- a/packages/@react-aria/datepicker/src/useDatePicker.ts
+++ b/packages/@react-aria/datepicker/src/useDatePicker.ts
@@ -149,7 +149,8 @@ export function useDatePicker<T extends DateValue>(props: AriaDatePickerProps<T>
       // DatePicker owns the validation state for the date field.
       [privateValidationStateProp]: state,
       autoFocus: props.autoFocus,
-      name: props.name
+      name: props.name,
+      form: props.form
     },
     descriptionProps,
     errorMessageProps,
diff --git a/packages/@react-aria/datepicker/src/useDateRangePicker.ts b/packages/@react-aria/datepicker/src/useDateRangePicker.ts
index 7f80ee33578..1009798371c 100644
--- a/packages/@react-aria/datepicker/src/useDateRangePicker.ts
+++ b/packages/@react-aria/datepicker/src/useDateRangePicker.ts
@@ -186,6 +186,7 @@ export function useDateRangePicker<T extends DateValue>(props: AriaDateRangePick
       onChange: start => state.setDateTime('start', start),
       autoFocus: props.autoFocus,
       name: props.startName,
+      form: props.form,
       [privateValidationStateProp]: {
         realtimeValidation: state.realtimeValidation,
         displayValidation: state.displayValidation,
@@ -203,6 +204,7 @@ export function useDateRangePicker<T extends DateValue>(props: AriaDateRangePick
       value: state.value?.end ?? null,
       onChange: end => state.setDateTime('end', end),
       name: props.endName,
+      form: props.form,
       [privateValidationStateProp]: {
         realtimeValidation: state.realtimeValidation,
         displayValidation: state.displayValidation,
diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts
index 9411cfb9570..b056f140e09 100644
--- a/packages/@react-aria/numberfield/src/useNumberField.ts
+++ b/packages/@react-aria/numberfield/src/useNumberField.ts
@@ -195,7 +195,9 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt
   let {labelProps, inputProps: textFieldProps, descriptionProps, errorMessageProps} = useFormattedTextField({
     ...otherProps,
     ...domProps,
+    // These props are added to a hidden input rather than the formatted textfield.
     name: undefined,
+    form: undefined,
     label,
     autoFocus,
     isDisabled,
diff --git a/packages/@react-aria/radio/src/useRadio.ts b/packages/@react-aria/radio/src/useRadio.ts
index 3c4f0220a22..c2291b16b8a 100644
--- a/packages/@react-aria/radio/src/useRadio.ts
+++ b/packages/@react-aria/radio/src/useRadio.ts
@@ -93,7 +93,7 @@ export function useRadio(props: AriaRadioProps, state: RadioGroupState, ref: Ref
     tabIndex = undefined;
   }
 
-  let {name, descriptionId, errorMessageId, validationBehavior} = radioGroupData.get(state)!;
+  let {name, form, descriptionId, errorMessageId, validationBehavior} = radioGroupData.get(state)!;
   useFormReset(ref, state.selectedValue, state.setSelectedValue);
   useFormValidation({validationBehavior}, state, ref);
 
@@ -103,6 +103,7 @@ export function useRadio(props: AriaRadioProps, state: RadioGroupState, ref: Ref
       ...interactions,
       type: 'radio',
       name,
+      form,
       tabIndex,
       disabled: isDisabled,
       required: state.isRequired && validationBehavior === 'native',
diff --git a/packages/@react-aria/radio/src/useRadioGroup.ts b/packages/@react-aria/radio/src/useRadioGroup.ts
index 5db55e3da64..73a886495bb 100644
--- a/packages/@react-aria/radio/src/useRadioGroup.ts
+++ b/packages/@react-aria/radio/src/useRadioGroup.ts
@@ -40,6 +40,7 @@ export interface RadioGroupAria extends ValidationResult {
 export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState): RadioGroupAria {
   let {
     name,
+    form,
     isReadOnly,
     isRequired,
     isDisabled,
@@ -126,6 +127,7 @@ export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState
   let groupName = useId(name);
   radioGroupData.set(state, {
     name: groupName,
+    form,
     descriptionId: descriptionProps.id,
     errorMessageId: errorMessageProps.id,
     validationBehavior
diff --git a/packages/@react-aria/radio/src/utils.ts b/packages/@react-aria/radio/src/utils.ts
index 01fcfc88a52..0ba6ce2de12 100644
--- a/packages/@react-aria/radio/src/utils.ts
+++ b/packages/@react-aria/radio/src/utils.ts
@@ -14,6 +14,7 @@ import {RadioGroupState} from '@react-stately/radio';
 
 interface RadioGroupData {
   name: string,
+  form: string | undefined,
   descriptionId: string | undefined,
   errorMessageId: string | undefined,
   validationBehavior: 'aria' | 'native'
diff --git a/packages/@react-aria/select/src/HiddenSelect.tsx b/packages/@react-aria/select/src/HiddenSelect.tsx
index b0d61ddc2aa..1875b5b3ebc 100644
--- a/packages/@react-aria/select/src/HiddenSelect.tsx
+++ b/packages/@react-aria/select/src/HiddenSelect.tsx
@@ -30,6 +30,13 @@ export interface AriaHiddenSelectProps {
   /** HTML form input name. */
   name?: string,
 
+  /**
+   * The `<form>` element to associate the input with.
+   * The value of this attribute must be the id of a `<form>` in the same document.
+   * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form).
+   */
+  form?: string,
+
   /** Sets the disabled state of the select and input. */
   isDisabled?: boolean
 }
@@ -65,7 +72,7 @@ export interface HiddenSelectAria {
  */
 export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: SelectState<T>, triggerRef: RefObject<FocusableElement | null>): HiddenSelectAria {
   let data = selectData.get(state) || {};
-  let {autoComplete, name = data.name, isDisabled = data.isDisabled} = props;
+  let {autoComplete, name = data.name, form = data.form, isDisabled = data.isDisabled} = props;
   let {validationBehavior, isRequired} = data;
   let {visuallyHiddenProps} = useVisuallyHidden();
 
@@ -99,6 +106,7 @@ export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: Select
       disabled: isDisabled,
       required: validationBehavior === 'native' && isRequired,
       name,
+      form,
       value: state.selectedKey ?? undefined,
       onChange: (e: React.ChangeEvent<HTMLSelectElement>) => state.setSelectedKey(e.target.value)
     }
@@ -110,7 +118,7 @@ export function useHiddenSelect<T>(props: AriaHiddenSelectOptions, state: Select
  * form autofill, mobile form navigation, and native form submission.
  */
 export function HiddenSelect<T>(props: HiddenSelectProps<T>): JSX.Element | null {
-  let {state, triggerRef, label, name, isDisabled} = props;
+  let {state, triggerRef, label, name, form, isDisabled} = props;
   let selectRef = useRef(null);
   let {containerProps, selectProps} = useHiddenSelect({...props, selectRef}, state, triggerRef);
 
@@ -146,6 +154,7 @@ export function HiddenSelect<T>(props: HiddenSelectProps<T>): JSX.Element | null
         type="hidden"
         autoComplete={selectProps.autoComplete}
         name={name}
+        form={form}
         disabled={isDisabled}
         value={state.selectedKey ?? ''} />
     );
diff --git a/packages/@react-aria/select/src/useSelect.ts b/packages/@react-aria/select/src/useSelect.ts
index b9d954bd7d4..35200eabcb3 100644
--- a/packages/@react-aria/select/src/useSelect.ts
+++ b/packages/@react-aria/select/src/useSelect.ts
@@ -55,6 +55,7 @@ interface SelectData {
   isDisabled?: boolean,
   isRequired?: boolean,
   name?: string,
+  form?: string,
   validationBehavior?: 'aria' | 'native'
 }
 
@@ -72,6 +73,7 @@ export function useSelect<T>(props: AriaSelectOptions<T>, state: SelectState<T>,
     isDisabled,
     isRequired,
     name,
+    form,
     validationBehavior = 'aria'
   } = props;
 
@@ -142,6 +144,7 @@ export function useSelect<T>(props: AriaSelectOptions<T>, state: SelectState<T>,
     isDisabled,
     isRequired,
     name,
+    form,
     validationBehavior
   });
 
diff --git a/packages/@react-aria/slider/src/useSliderThumb.ts b/packages/@react-aria/slider/src/useSliderThumb.ts
index 75a1d29045c..8f255907077 100644
--- a/packages/@react-aria/slider/src/useSliderThumb.ts
+++ b/packages/@react-aria/slider/src/useSliderThumb.ts
@@ -51,7 +51,8 @@ export function useSliderThumb(
     trackRef,
     inputRef,
     orientation = state.orientation,
-    name
+    name,
+    form
   } = opts;
 
   let isDisabled = opts.isDisabled || state.isDisabled;
@@ -244,6 +245,7 @@ export function useSliderThumb(
       step: state.step,
       value: value,
       name,
+      form,
       disabled: isDisabled,
       'aria-orientation': orientation,
       'aria-valuetext': state.getThumbValueLabel(index),
diff --git a/packages/@react-aria/textfield/src/useTextField.ts b/packages/@react-aria/textfield/src/useTextField.ts
index 7a3bd98504f..77abbd59ea1 100644
--- a/packages/@react-aria/textfield/src/useTextField.ts
+++ b/packages/@react-aria/textfield/src/useTextField.ts
@@ -186,6 +186,7 @@ export function useTextField<T extends TextFieldIntrinsicElements = DefaultEleme
         maxLength: props.maxLength,
         minLength: props.minLength,
         name: props.name,
+        form: props.form,
         placeholder: props.placeholder,
         inputMode: props.inputMode,
         autoCorrect: props.autoCorrect,
diff --git a/packages/@react-aria/toggle/src/useToggle.ts b/packages/@react-aria/toggle/src/useToggle.ts
index fee8b760286..32da300efe9 100644
--- a/packages/@react-aria/toggle/src/useToggle.ts
+++ b/packages/@react-aria/toggle/src/useToggle.ts
@@ -43,6 +43,7 @@ export function useToggle(props: AriaToggleProps, state: ToggleState, ref: RefOb
     isReadOnly = false,
     value,
     name,
+    form,
     children,
     'aria-label': ariaLabel,
     'aria-labelledby': ariaLabelledby,
@@ -94,6 +95,7 @@ export function useToggle(props: AriaToggleProps, state: ToggleState, ref: RefOb
       disabled: isDisabled,
       ...(value == null ? {} : {value}),
       name,
+      form,
       type: 'checkbox',
       ...interactions
     }),
diff --git a/packages/@react-spectrum/numberfield/src/NumberField.tsx b/packages/@react-spectrum/numberfield/src/NumberField.tsx
index 8cc36864594..44ace71b7c5 100644
--- a/packages/@react-spectrum/numberfield/src/NumberField.tsx
+++ b/packages/@react-spectrum/numberfield/src/NumberField.tsx
@@ -189,7 +189,7 @@ const NumberFieldInput = React.forwardRef(function NumberFieldInput(props: Numbe
           <StepButton direction="down" isQuiet={isQuiet} {...decrementProps} />
         </>
         }
-        {name && <input type="hidden" name={name} value={isNaN(state.numberValue) ? '' : state.numberValue} />}
+        {name && <input type="hidden" name={name} form={props.form} value={isNaN(state.numberValue) ? '' : state.numberValue} />}
       </div>
     </FocusRing>
   );
diff --git a/packages/@react-spectrum/numberfield/test/NumberField.test.js b/packages/@react-spectrum/numberfield/test/NumberField.test.js
index b9ff89c22cf..4ba4883647d 100644
--- a/packages/@react-spectrum/numberfield/test/NumberField.test.js
+++ b/packages/@react-spectrum/numberfield/test/NumberField.test.js
@@ -2272,10 +2272,11 @@ describe('NumberField', function () {
   });
 
   it('supports form value', () => {
-    let {textField, rerender} = renderNumberField({name: 'age', value: 30});
+    let {textField, rerender} = renderNumberField({name: 'age', form: 'test', value: 30});
     expect(textField).not.toHaveAttribute('name');
     let hiddenInput = document.querySelector('input[type=hidden]');
     expect(hiddenInput).toHaveAttribute('name', 'age');
+    expect(hiddenInput).toHaveAttribute('form', 'test');
     expect(hiddenInput).toHaveValue('30');
 
     rerender({name: 'age', value: null});
diff --git a/packages/@react-spectrum/picker/src/Picker.tsx b/packages/@react-spectrum/picker/src/Picker.tsx
index 67fca8f89c1..ee3da9e54cc 100644
--- a/packages/@react-spectrum/picker/src/Picker.tsx
+++ b/packages/@react-spectrum/picker/src/Picker.tsx
@@ -63,6 +63,7 @@ export const Picker = React.forwardRef(function Picker<T extends object>(props:
     labelPosition = 'top' as LabelPosition,
     menuWidth,
     name,
+    form,
     autoFocus
   } = props;
 
@@ -184,7 +185,8 @@ export const Picker = React.forwardRef(function Picker<T extends object>(props:
         state={state}
         triggerRef={unwrappedTriggerRef}
         label={label}
-        name={name} />
+        name={name}
+        form={form} />
       <PressResponder {...mergeProps(hoverProps, triggerProps)}>
         <FieldButton
           ref={triggerRef}
diff --git a/packages/@react-spectrum/picker/test/Picker.test.js b/packages/@react-spectrum/picker/test/Picker.test.js
index da7c4ad6960..8f61bd6c013 100644
--- a/packages/@react-spectrum/picker/test/Picker.test.js
+++ b/packages/@react-spectrum/picker/test/Picker.test.js
@@ -2108,6 +2108,21 @@ describe('Picker', function () {
       expect(input).toHaveValue('one');
     });
 
+    it('should support form prop', () => {
+      render(
+        <Provider theme={theme}>
+          <Picker label="Test" name="picker" form="test">
+            <Item key="one">One</Item>
+            <Item key="two">Two</Item>
+            <Item key="three">Three</Item>
+          </Picker>
+        </Provider>
+      );
+
+      let input = document.querySelector('[name=picker]');
+      expect(input).toHaveAttribute('form', 'test');
+    });
+
     describe('validation', () => {
       describe('validationBehavior=native', () => {
         it('supports isRequired', async () => {
diff --git a/packages/@react-spectrum/s2/src/RangeSlider.tsx b/packages/@react-spectrum/s2/src/RangeSlider.tsx
index e197db55f1e..c51f0256555 100644
--- a/packages/@react-spectrum/s2/src/RangeSlider.tsx
+++ b/packages/@react-spectrum/s2/src/RangeSlider.tsx
@@ -34,7 +34,13 @@ export interface RangeSliderProps extends Omit<SliderBaseProps<RangeValue<number
   /**
    * The name of the end input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname).
    */
-  endName?: string
+  endName?: string,
+  /**
+   * The `<form>` element to associate the input with.
+   * The value of this attribute must be the id of a `<form>` in the same document.
+   * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form).
+   */
+  form?: string
 }
 
 export const RangeSliderContext = createContext<ContextValue<Partial<RangeSliderProps>, FocusableRefValue<HTMLDivElement>>>(null);
@@ -82,6 +88,7 @@ export const RangeSlider = /*#__PURE__*/ forwardRef(function RangeSlider(props:
               className={thumbContainer}
               index={0}
               name={props.startName}
+              form={props.form}
               aria-label={stringFormatter.format('slider.minimum')}
               ref={lowerThumbRef}
               style={(renderProps) => pressScale(lowerThumbRef, {
@@ -103,6 +110,7 @@ export const RangeSlider = /*#__PURE__*/ forwardRef(function RangeSlider(props:
               className={thumbContainer}
               index={1}
               name={props.endName}
+              form={props.form}
               aria-label={stringFormatter.format('slider.maximum')}
               ref={upperThumbRef}
               style={(renderProps) => pressScale(upperThumbRef, {
diff --git a/packages/@react-spectrum/s2/src/Slider.tsx b/packages/@react-spectrum/s2/src/Slider.tsx
index 657a17b1bab..0431cee0c5a 100644
--- a/packages/@react-spectrum/s2/src/Slider.tsx
+++ b/packages/@react-spectrum/s2/src/Slider.tsx
@@ -426,7 +426,7 @@ export const Slider = /*#__PURE__*/ forwardRef(function Slider(props: SliderProp
             <>
               <div className={upperTrack({isDisabled, trackStyle})} />
               <div style={{width: `${Math.abs(fillWidth) * 100}%`, [cssDirection]: `${offset * 100}%`}} className={filledTrack({isDisabled, isEmphasized, trackStyle})} />
-              <SliderThumb  className={thumbContainer} index={0} name={props.name} ref={thumbRef} style={(renderProps) => pressScale(thumbRef, {transform: 'translate(-50%, -50%)'})({...renderProps, isPressed: renderProps.isDragging})}>
+              <SliderThumb  className={thumbContainer} index={0} name={props.name} form={props.form} ref={thumbRef} style={(renderProps) => pressScale(thumbRef, {transform: 'translate(-50%, -50%)'})({...renderProps, isPressed: renderProps.isDragging})}>
                 {(renderProps) => (
                   <div className={thumbHitArea({size})}>
                     <div
diff --git a/packages/@react-spectrum/slider/src/RangeSlider.tsx b/packages/@react-spectrum/slider/src/RangeSlider.tsx
index 268d2d647d8..7f6b7405f61 100644
--- a/packages/@react-spectrum/slider/src/RangeSlider.tsx
+++ b/packages/@react-spectrum/slider/src/RangeSlider.tsx
@@ -62,7 +62,8 @@ export const RangeSlider = React.forwardRef(function RangeSlider(props: Spectrum
               trackRef={trackRef}
               inputRef={inputRef}
               state={state}
-              name={props.startName} />
+              name={props.startName}
+              form={props.form} />
             <div
               className={classNames(styles, 'spectrum-Slider-track')}
               style={{
@@ -75,7 +76,8 @@ export const RangeSlider = React.forwardRef(function RangeSlider(props: Spectrum
               isDisabled={props.isDisabled}
               trackRef={trackRef}
               state={state}
-              name={props.endName} />
+              name={props.endName}
+              form={props.form} />
             <div
               className={classNames(styles, 'spectrum-Slider-track')}
               style={{
diff --git a/packages/@react-spectrum/slider/src/Slider.tsx b/packages/@react-spectrum/slider/src/Slider.tsx
index 40c36182189..fa531df2a6b 100644
--- a/packages/@react-spectrum/slider/src/Slider.tsx
+++ b/packages/@react-spectrum/slider/src/Slider.tsx
@@ -107,7 +107,8 @@ export const Slider = React.forwardRef(function Slider(props: SpectrumSliderProp
               trackRef={trackRef}
               inputRef={inputRef}
               state={state}
-              name={props.name} />
+              name={props.name}
+              form={props.form} />
             {filledTrack}
             {upperTrack}
           </>
diff --git a/packages/@react-spectrum/slider/test/RangeSlider.test.tsx b/packages/@react-spectrum/slider/test/RangeSlider.test.tsx
index 95c7096ba0a..db9d210ee77 100644
--- a/packages/@react-spectrum/slider/test/RangeSlider.test.tsx
+++ b/packages/@react-spectrum/slider/test/RangeSlider.test.tsx
@@ -189,11 +189,13 @@ describe('RangeSlider', function () {
   });
 
   it('supports form name', () => {
-    let {getAllByRole} = render(<RangeSlider label="Value" value={{start: 10, end: 40}} startName="minCookies" endName="maxCookies" />);
+    let {getAllByRole} = render(<RangeSlider label="Value" value={{start: 10, end: 40}} startName="minCookies" endName="maxCookies" form="test" />);
     let inputs = getAllByRole('slider');
     expect(inputs[0]).toHaveAttribute('name', 'minCookies');
+    expect(inputs[0]).toHaveAttribute('form', 'test');
     expect(inputs[0]).toHaveValue('10');
     expect(inputs[1]).toHaveAttribute('name', 'maxCookies');
+    expect(inputs[1]).toHaveAttribute('form', 'test');
     expect(inputs[1]).toHaveValue('40');
   });
 
diff --git a/packages/@react-spectrum/slider/test/Slider.test.tsx b/packages/@react-spectrum/slider/test/Slider.test.tsx
index c5bcba26e04..41b255e9d01 100644
--- a/packages/@react-spectrum/slider/test/Slider.test.tsx
+++ b/packages/@react-spectrum/slider/test/Slider.test.tsx
@@ -193,9 +193,10 @@ describe('Slider', function () {
   });
 
   it('supports form name', () => {
-    let {getByRole} = render(<Slider label="Value" value={10} name="cookies" />);
+    let {getByRole} = render(<Slider label="Value" value={10} name="cookies" form="test" />);
     let input = getByRole('slider');
     expect(input).toHaveAttribute('name', 'cookies');
+    expect(input).toHaveAttribute('form', 'test');
     expect(input).toHaveValue('10');
   });
 
diff --git a/packages/@react-types/button/src/index.d.ts b/packages/@react-types/button/src/index.d.ts
index 4c7ee4ffa5a..49a0b65fb08 100644
--- a/packages/@react-types/button/src/index.d.ts
+++ b/packages/@react-types/button/src/index.d.ts
@@ -68,7 +68,30 @@ interface AriaBaseButtonProps extends FocusableDOMProps, AriaLabelingProps {
    * Caution, this can make the button inaccessible and should only be used when alternative keyboard interaction is provided,
    * such as ComboBox's MenuTrigger or a NumberField's increment/decrement control.
    */
-  preventFocusOnPress?: boolean
+  preventFocusOnPress?: boolean,
+  /**
+   * The `<form>` element to associate the button with.
+   * The value of this attribute must be the id of a `<form>` in the same document.
+   * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#form).
+   */
+  form?: string,
+  /**
+   * The URL that processes the information submitted by the button.
+   * Overrides the action attribute of the button's form owner.
+   */
+  formAction?: string,
+  /** Indicates how to encode the form data that is submitted. */
+  formEncType?: string,
+  /** Indicates the HTTP method used to submit the form. */
+  formMethod?: string,
+  /** Indicates that the form is not to be validated when it is submitted. */
+  formNoValidate?: boolean,
+  /** Overrides the target attribute of the button's form owner. */
+  formTarget?: string,
+  /** Submitted as a pair with the button's value as part of the form data. */
+  name?: string,
+  /** The value associated with the button's name when it's submitted with the form data. */
+  value?: string
 }
 
 export interface AriaButtonProps<T extends ElementType = 'button'> extends ButtonProps, LinkButtonProps<T>, AriaBaseButtonProps {}
diff --git a/packages/@react-types/color/src/index.d.ts b/packages/@react-types/color/src/index.d.ts
index 7350c51497d..51495ca50cc 100644
--- a/packages/@react-types/color/src/index.d.ts
+++ b/packages/@react-types/color/src/index.d.ts
@@ -208,7 +208,13 @@ export interface AriaColorAreaProps extends ColorAreaProps, DOMProps, AriaLabeli
   /**
    * The name of the y channel input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname).
    */
-  yName?: string
+  yName?: string,
+  /**
+   * The `<form>` element to associate the ColorArea with.
+   * The value of this attribute must be the id of a `<form>` in the same document.
+   * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form).
+   */
+  form?: string
 }
 
 export interface SpectrumColorAreaProps extends AriaColorAreaProps, Omit<StyleProps, 'width' | 'height'> {
diff --git a/packages/@react-types/datepicker/src/index.d.ts b/packages/@react-types/datepicker/src/index.d.ts
index 15320daab9d..1246d923a9b 100644
--- a/packages/@react-types/datepicker/src/index.d.ts
+++ b/packages/@react-types/datepicker/src/index.d.ts
@@ -96,7 +96,13 @@ export interface DateRangePickerProps<T extends DateValue> extends Omit<DatePick
   /**
    * The name of the end date input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname).
    */
-  endName?: string
+  endName?: string,
+  /**
+   * The `<form>` element to associate the input with.
+   * The value of this attribute must be the id of a `<form>` in the same document.
+   * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form).
+   */
+  form?: string
 }
 
 export interface AriaDateRangePickerProps<T extends DateValue> extends Omit<AriaDatePickerBaseProps<T>, 'validate'>, DateRangePickerProps<T> {}
diff --git a/packages/@react-types/select/src/index.d.ts b/packages/@react-types/select/src/index.d.ts
index d34a6c9faad..22b6f976801 100644
--- a/packages/@react-types/select/src/index.d.ts
+++ b/packages/@react-types/select/src/index.d.ts
@@ -47,7 +47,13 @@ export interface AriaSelectProps<T> extends SelectProps<T>, DOMProps, AriaLabeli
   /**
    * The name of the input, used when submitting an HTML form.
    */
-  name?: string
+  name?: string,
+  /**
+   * The `<form>` element to associate the input with.
+   * The value of this attribute must be the id of a `<form>` in the same document.
+   * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form).
+   */
+  form?: string
 }
 
 export interface SpectrumPickerProps<T> extends AriaSelectProps<T>, AsyncLoadable, SpectrumLabelableProps, StyleProps  {
diff --git a/packages/@react-types/shared/src/dom.d.ts b/packages/@react-types/shared/src/dom.d.ts
index d6acd30ba68..8b8cc36fab3 100644
--- a/packages/@react-types/shared/src/dom.d.ts
+++ b/packages/@react-types/shared/src/dom.d.ts
@@ -127,7 +127,13 @@ export interface InputDOMProps {
   /**
    * The name of the input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname).
    */
-  name?: string
+  name?: string,
+  /**
+   * The `<form>` element to associate the input with.
+   * The value of this attribute must be the id of a `<form>` in the same document.
+   * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form).
+   */
+  form?: string
 }
 
 // DOM props that apply to all text inputs
diff --git a/packages/@react-types/slider/src/index.d.ts b/packages/@react-types/slider/src/index.d.ts
index 6603a484636..023516f228e 100644
--- a/packages/@react-types/slider/src/index.d.ts
+++ b/packages/@react-types/slider/src/index.d.ts
@@ -117,5 +117,11 @@ export interface SpectrumRangeSliderProps extends SpectrumBarSliderBase<RangeVal
   /**
    * The name of the end input element, used when submitting an HTML form. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname).
    */
-  endName?: string
+  endName?: string,
+  /**
+   * The `<form>` element to associate the slider with.
+   * The value of this attribute must be the id of a `<form>` in the same document.
+   * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form).
+   */
+  form?: string
 }
diff --git a/packages/react-aria-components/src/Button.tsx b/packages/react-aria-components/src/Button.tsx
index 808e169332b..ee843f8cd61 100644
--- a/packages/react-aria-components/src/Button.tsx
+++ b/packages/react-aria-components/src/Button.tsx
@@ -66,28 +66,6 @@ export interface ButtonRenderProps {
 }
 
 export interface ButtonProps extends Omit<AriaButtonProps, 'children' | 'href' | 'target' | 'rel' | 'elementType'>, HoverEvents, SlotProps, RenderProps<ButtonRenderProps> {
-  /**
-   * The `<form>` element to associate the button with.
-   * The value of this attribute must be the id of a `<form>` in the same document.
-   */
-  form?: string,
-  /**
-   * The URL that processes the information submitted by the button.
-   * Overrides the action attribute of the button's form owner.
-   */
-  formAction?: string,
-  /** Indicates how to encode the form data that is submitted. */
-  formEncType?: string,
-  /** Indicates the HTTP method used to submit the form. */
-  formMethod?: string,
-  /** Indicates that the form is not to be validated when it is submitted. */
-  formNoValidate?: boolean,
-  /** Overrides the target attribute of the button's form owner. */
-  formTarget?: string,
-  /** Submitted as a pair with the button's value as part of the form data. */
-  name?: string,
-  /** The value associated with the button's name when it's submitted with the form data. */
-  value?: string,
   /**
    * Whether the button is in a pending state. This disables press and hover events
    * while retaining focusability, and announces the pending state to screen readers.
@@ -99,8 +77,6 @@ interface ButtonContextValue extends ButtonProps {
   isPressed?: boolean
 }
 
-const additionalButtonHTMLAttributes = new Set(['form', 'formAction', 'formEncType', 'formMethod', 'formNoValidate', 'formTarget', 'name', 'value']);
-
 export const ButtonContext = createContext<ContextValue<ButtonContextValue, HTMLButtonElement>>({});
 
 /**
@@ -161,7 +137,7 @@ export const Button = /*#__PURE__*/ createHideableComponent(function Button(prop
   // We do this by changing the button's type to button.
   return (
     <button
-      {...filterDOMProps(props, {propNames: additionalButtonHTMLAttributes})}
+      {...filterDOMProps(props)}
       {...mergeProps(buttonProps, focusProps, hoverProps)}
       {...renderProps}
       type={buttonProps.type === 'submit' && isPending ? 'button' : buttonProps.type}
diff --git a/packages/react-aria-components/src/NumberField.tsx b/packages/react-aria-components/src/NumberField.tsx
index 375b9ff7ab0..4450f4cfac4 100644
--- a/packages/react-aria-components/src/NumberField.tsx
+++ b/packages/react-aria-components/src/NumberField.tsx
@@ -127,7 +127,7 @@ export const NumberField = /*#__PURE__*/ (forwardRef as forwardRefType)(function
         data-disabled={props.isDisabled || undefined}
         data-required={props.isRequired || undefined}
         data-invalid={validation.isInvalid || undefined} />
-      {props.name && <input type="hidden" name={props.name} value={isNaN(state.numberValue) ? '' : state.numberValue} />}
+      {props.name && <input type="hidden" name={props.name} form={props.form} value={isNaN(state.numberValue) ? '' : state.numberValue} />}
     </Provider>
   );
 });
diff --git a/packages/react-aria-components/src/Select.tsx b/packages/react-aria-components/src/Select.tsx
index d96efb4dc23..06cb3e62223 100644
--- a/packages/react-aria-components/src/Select.tsx
+++ b/packages/react-aria-components/src/Select.tsx
@@ -220,6 +220,7 @@ function SelectInner<T extends object>({props, selectRef: ref, collection}: Sele
         triggerRef={buttonRef}
         label={label}
         name={props.name}
+        form={props.form}
         isDisabled={props.isDisabled} />
     </Provider>
   );
diff --git a/packages/react-aria-components/test/Checkbox.test.js b/packages/react-aria-components/test/Checkbox.test.js
index 6754c5dd5ef..5a2e7b969a5 100644
--- a/packages/react-aria-components/test/Checkbox.test.js
+++ b/packages/react-aria-components/test/Checkbox.test.js
@@ -244,4 +244,10 @@ describe('Checkbox', () => {
     expect(inputRef.current).toBe(getByRole('checkbox'));
     expect(contextInputRef.current).toBe(getByRole('checkbox'));
   });
+
+  it('should support form prop', () => {
+    let {getByRole} = render(<Checkbox form="test">Test</Checkbox>);
+    let checkbox = getByRole('checkbox');
+    expect(checkbox).toHaveAttribute('form', 'test');
+  });
 });
diff --git a/packages/react-aria-components/test/CheckboxGroup.test.js b/packages/react-aria-components/test/CheckboxGroup.test.js
index 8879d6d506d..dea4796fa24 100644
--- a/packages/react-aria-components/test/CheckboxGroup.test.js
+++ b/packages/react-aria-components/test/CheckboxGroup.test.js
@@ -281,4 +281,11 @@ describe('CheckboxGroup', () => {
     expect(onFocusChange).toHaveBeenCalledTimes(2);  // triggered by onBlur
     expect(onFocusChange).toHaveBeenLastCalledWith(false);
   });
+
+  it('should support form prop', () => {
+    let {getAllByRole} = renderGroup({form: 'test'});
+    for (let checkbox of getAllByRole('checkbox')) {
+      expect(checkbox).toHaveAttribute('form', 'test');
+    }
+  });
 });
diff --git a/packages/react-aria-components/test/ColorArea.test.js b/packages/react-aria-components/test/ColorArea.test.js
index 9633d8dc788..56be2f783d8 100644
--- a/packages/react-aria-components/test/ColorArea.test.js
+++ b/packages/react-aria-components/test/ColorArea.test.js
@@ -144,4 +144,10 @@ describe('ColorArea', () => {
     expect(wrapper).toHaveAttribute('data-disabled', 'true');
     expect(wrapper).toHaveClass('disabled');
   });
+
+  it('should support form prop', () => {
+    let {getByRole} = renderColorArea({form: 'test'});
+    let input = getByRole('slider');
+    expect(input).toHaveAttribute('form', 'test');
+  });
 });
diff --git a/packages/react-aria-components/test/ColorField.test.js b/packages/react-aria-components/test/ColorField.test.js
index 9e26ef4589a..7b53495c326 100644
--- a/packages/react-aria-components/test/ColorField.test.js
+++ b/packages/react-aria-components/test/ColorField.test.js
@@ -145,4 +145,13 @@ describe('ColorField', () => {
     await user.tab();
     expect(onChange).toHaveBeenCalledWith(parseColor('hsl(100, 25%, 73.33%)'));
   });
+
+  it('should support form prop', () => {
+    let {getByRole} = render(
+      <TestColorField form="test" />
+    );
+
+    let input = getByRole('textbox');
+    expect(input).toHaveAttribute('form', 'test');
+  });
 });
diff --git a/packages/react-aria-components/test/ColorSlider.test.js b/packages/react-aria-components/test/ColorSlider.test.js
index cba139981ca..455ff5e87bc 100644
--- a/packages/react-aria-components/test/ColorSlider.test.js
+++ b/packages/react-aria-components/test/ColorSlider.test.js
@@ -186,4 +186,10 @@ describe('ColorSlider', () => {
     expect(wrapper).toHaveClass('vertical');
     expect(slider).toHaveAttribute('aria-orientation', 'vertical');
   });
+
+  it('should support form prop', () => {
+    let {getByRole} = renderSlider({form: 'test'});
+    let input = getByRole('slider');
+    expect(input).toHaveAttribute('form', 'test');
+  });
 });
diff --git a/packages/react-aria-components/test/ColorWheel.test.js b/packages/react-aria-components/test/ColorWheel.test.js
index 911ddaa4926..971c75e00e9 100644
--- a/packages/react-aria-components/test/ColorWheel.test.js
+++ b/packages/react-aria-components/test/ColorWheel.test.js
@@ -144,4 +144,10 @@ describe('ColorWheel', () => {
     expect(wrapper).toHaveAttribute('data-disabled', 'true');
     expect(wrapper).toHaveClass('disabled');
   });
+
+  it('should support form prop', () => {
+    let {getByRole} = renderColorWheel({form: 'test'});
+    let input = getByRole('slider');
+    expect(input).toHaveAttribute('form', 'test');
+  });
 });
diff --git a/packages/react-aria-components/test/ComboBox.test.js b/packages/react-aria-components/test/ComboBox.test.js
index 670362f6a65..8b3a2401ff9 100644
--- a/packages/react-aria-components/test/ComboBox.test.js
+++ b/packages/react-aria-components/test/ComboBox.test.js
@@ -378,4 +378,10 @@ describe('ComboBox', () => {
     let text = popover.querySelector('.react-aria-Text');
     expect(text).not.toHaveAttribute('id');
   });
+
+  it('should support form prop', () => {
+    let {getByRole} = render(<TestComboBox form="test" />);
+    let input = getByRole('combobox');
+    expect(input).toHaveAttribute('form', 'test');
+  });
 });
diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js
index bdbd26a1ba7..4efff22ccb6 100644
--- a/packages/react-aria-components/test/DateField.test.js
+++ b/packages/react-aria-components/test/DateField.test.js
@@ -218,7 +218,7 @@ describe('DateField', () => {
 
   it('should support form value', () => {
     render(
-      <DateField name="birthday" value={new CalendarDate(2020, 2, 3)}>
+      <DateField name="birthday" form="test" value={new CalendarDate(2020, 2, 3)}>
         <Label>Birth date</Label>
         <DateInput>
           {segment => <DateSegment segment={segment} />}
@@ -227,6 +227,7 @@ describe('DateField', () => {
     );
     let input = document.querySelector('input[name=birthday]');
     expect(input).toHaveValue('2020-02-03');
+    expect(input).toHaveAttribute('form', 'test');
   });
 
   it('should render data- attributes only on the outer element', () => {
diff --git a/packages/react-aria-components/test/DatePicker.test.js b/packages/react-aria-components/test/DatePicker.test.js
index 59a0239bc8f..5f60bca8b30 100644
--- a/packages/react-aria-components/test/DatePicker.test.js
+++ b/packages/react-aria-components/test/DatePicker.test.js
@@ -153,9 +153,10 @@ describe('DatePicker', () => {
   });
 
   it('should support form value', () => {
-    render(<TestDatePicker name="birthday" value={new CalendarDate(2020, 2, 3)} />);
+    render(<TestDatePicker name="birthday" form="test" value={new CalendarDate(2020, 2, 3)} />);
     let input = document.querySelector('input[name=birthday]');
     expect(input).toHaveValue('2020-02-03');
+    expect(input).toHaveAttribute('form', 'test');
   });
 
   it('should render data- attributes only on the outer element', () => {
diff --git a/packages/react-aria-components/test/DateRangePicker.test.js b/packages/react-aria-components/test/DateRangePicker.test.js
index aab7089ba2a..f861a9d54fd 100644
--- a/packages/react-aria-components/test/DateRangePicker.test.js
+++ b/packages/react-aria-components/test/DateRangePicker.test.js
@@ -163,11 +163,13 @@ describe('DateRangePicker', () => {
   });
 
   it('should support form value', () => {
-    render(<TestDateRangePicker startName="start" endName="end" value={{start: new CalendarDate(2023, 1, 10), end: new CalendarDate(2023, 1, 20)}} />);
+    render(<TestDateRangePicker startName="start" endName="end" form="test" value={{start: new CalendarDate(2023, 1, 10), end: new CalendarDate(2023, 1, 20)}} />);
     let start = document.querySelector('input[name=start]');
     expect(start).toHaveValue('2023-01-10');
+    expect(start).toHaveAttribute('form', 'test');
     let end = document.querySelector('input[name=end]');
     expect(end).toHaveValue('2023-01-20');
+    expect(end).toHaveAttribute('form', 'test');
   });
 
   it('should render data- attributes only on the outer element', () => {
diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js
index 29de5db0698..14c3e0db770 100644
--- a/packages/react-aria-components/test/NumberField.test.js
+++ b/packages/react-aria-components/test/NumberField.test.js
@@ -126,11 +126,12 @@ describe('NumberField', () => {
   });
 
   it('should support form value', () => {
-    let {rerender} = render(<TestNumberField name="test" value={25} formatOptions={{style: 'currency', currency: 'USD'}} />);
+    let {rerender} = render(<TestNumberField name="test" form="test" value={25} formatOptions={{style: 'currency', currency: 'USD'}} />);
     let input = document.querySelector('input[name=test]');
     expect(input).toHaveValue('25');
+    expect(input).toHaveAttribute('form', 'test');
 
-    rerender(<TestNumberField name="test" value={null} formatOptions={{style: 'currency', currency: 'USD'}} />);
+    rerender(<TestNumberField name="test" form="test" value={null} formatOptions={{style: 'currency', currency: 'USD'}} />);
     expect(input).toHaveValue('');
   });
 
diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js
index bab80b346b3..221439757f1 100644
--- a/packages/react-aria-components/test/RadioGroup.test.js
+++ b/packages/react-aria-components/test/RadioGroup.test.js
@@ -557,4 +557,11 @@ describe('RadioGroup', () => {
     expect(inputRef.current).toBe(radio);
     expect(contextInputRef.current).toBe(radio);
   });
+
+  it('should support form prop', () => {
+    let {getAllByRole} = renderGroup({form: 'test'});
+    for (let radio of getAllByRole('radio')) {
+      expect(radio).toHaveAttribute('form', 'test');
+    }
+  });
 });
diff --git a/packages/react-aria-components/test/SearchField.test.js b/packages/react-aria-components/test/SearchField.test.js
index 6573011c526..736273c8220 100644
--- a/packages/react-aria-components/test/SearchField.test.js
+++ b/packages/react-aria-components/test/SearchField.test.js
@@ -126,4 +126,13 @@ describe('SearchField', () => {
     await user.tab();
     expect(input).not.toHaveAttribute('aria-describedby');
   });
+
+  it('should support form prop', () => {
+    let {getByRole} = render(
+      <TestSearchField form="test" />
+    );
+
+    let input = getByRole('searchbox');
+    expect(input).toHaveAttribute('form', 'test');
+  });
 });
diff --git a/packages/react-aria-components/test/Select.test.js b/packages/react-aria-components/test/Select.test.js
index 66b6a213bea..c98378e8afe 100644
--- a/packages/react-aria-components/test/Select.test.js
+++ b/packages/react-aria-components/test/Select.test.js
@@ -413,4 +413,13 @@ describe('Select', () => {
     let text = popover.querySelector('.react-aria-Text');
     expect(text).not.toHaveAttribute('id');
   });
+
+  it('should support form prop', () => {
+    render(
+      <TestSelect name="select" form="test" />
+    );
+
+    let input = document.querySelector('[name=select]');
+    expect(input).toHaveAttribute('form', 'test');
+  });
 });
diff --git a/packages/react-aria-components/test/Slider.test.js b/packages/react-aria-components/test/Slider.test.js
index 720d2afab98..888cbcc8d9d 100644
--- a/packages/react-aria-components/test/Slider.test.js
+++ b/packages/react-aria-components/test/Slider.test.js
@@ -274,22 +274,29 @@ describe('Slider', () => {
     await user.pointer([{target: track, keys: '[MouseLeft]', coords: {x: 20}}]);
     expect(onChange).toHaveBeenCalled();
   });
-});
 
-it('should support input ref', () => {
-  let inputRef = React.createRef();
-
-  let {getByRole} = render(
-    <Slider>
-      <Label>Test</Label>
-      <SliderOutput />
-      <SliderTrack>
-        <SliderThumb inputRef={inputRef} />
-      </SliderTrack>
-    </Slider>
-  );
-
-  let group = getByRole('group');
-  let thumbInput = group.querySelector('input');
-  expect(inputRef.current).toBe(thumbInput);
+  it('should support input ref', () => {
+    let inputRef = React.createRef();
+  
+    let {getByRole} = render(
+      <Slider>
+        <Label>Test</Label>
+        <SliderOutput />
+        <SliderTrack>
+          <SliderThumb inputRef={inputRef} />
+        </SliderTrack>
+      </Slider>
+    );
+  
+    let group = getByRole('group');
+    let thumbInput = group.querySelector('input');
+    expect(inputRef.current).toBe(thumbInput);
+  });
+
+  it('should support form prop', () => {
+    let {getByRole} = renderSlider({}, {form: 'test'});
+    let input = getByRole('slider');
+    expect(input).toHaveAttribute('form', 'test');
+  });
 });
+
diff --git a/packages/react-aria-components/test/Switch.test.js b/packages/react-aria-components/test/Switch.test.js
index 987088625a8..748471e9a0e 100644
--- a/packages/react-aria-components/test/Switch.test.js
+++ b/packages/react-aria-components/test/Switch.test.js
@@ -229,4 +229,10 @@ describe('Switch', () => {
     expect(inputRef.current).toBe(getByRole('switch'));
     expect(contextInputRef.current).toBe(getByRole('switch'));
   });
+
+  it('should support form prop', () => {
+    let {getByRole} = render(<Switch form="test">Test</Switch>);
+    let input = getByRole('switch');
+    expect(input).toHaveAttribute('form', 'test');
+  });
 });
diff --git a/packages/react-aria-components/test/TextField.test.js b/packages/react-aria-components/test/TextField.test.js
index 527c6644b5a..a5d354ee4c7 100644
--- a/packages/react-aria-components/test/TextField.test.js
+++ b/packages/react-aria-components/test/TextField.test.js
@@ -257,5 +257,14 @@ describe('TextField', () => {
       expect(input).toHaveAttribute('id', 'name');
       expect(label).toHaveAttribute('for', 'name');
     });
+
+    it('should support form prop', () => {
+      let {getByRole} = render(
+        <TestTextField form="test" input={component} />
+      );
+
+      let input = getByRole('textbox');
+      expect(input).toHaveAttribute('form', 'test');
+    });
   });
 });
diff --git a/packages/react-aria-components/test/TimeField.test.js b/packages/react-aria-components/test/TimeField.test.js
index 9315b8b91cb..80e85e481a8 100644
--- a/packages/react-aria-components/test/TimeField.test.js
+++ b/packages/react-aria-components/test/TimeField.test.js
@@ -133,7 +133,7 @@ describe('TimeField', () => {
 
   it('should support form value', () => {
     render(
-      <TimeField name="time" value={new Time(8, 30)}>
+      <TimeField name="time" form="test" value={new Time(8, 30)}>
         <Label>Time</Label>
         <DateInput>
           {segment => <DateSegment segment={segment} />}
@@ -142,6 +142,7 @@ describe('TimeField', () => {
     );
     let input = document.querySelector('input[name=time]');
     expect(input).toHaveValue('08:30:00');
+    expect(input).toHaveAttribute('form', 'test');
   });
 
   it('supports validation errors', async () => {

From cb63c90758892ef19f9c2bfbe411df63b482d573 Mon Sep 17 00:00:00 2001
From: Devon Govett <devongovett@gmail.com>
Date: Wed, 25 Jun 2025 13:48:13 -0700
Subject: [PATCH 2/2] add missing

---
 packages/@react-spectrum/color/src/ColorField.tsx  | 2 +-
 packages/@react-spectrum/combobox/src/ComboBox.tsx | 2 +-
 packages/react-aria-components/src/ColorField.tsx  | 2 +-
 packages/react-aria-components/src/ComboBox.tsx    | 2 +-
 4 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/packages/@react-spectrum/color/src/ColorField.tsx b/packages/@react-spectrum/color/src/ColorField.tsx
index 4e4c0f79100..db92d864122 100644
--- a/packages/@react-spectrum/color/src/ColorField.tsx
+++ b/packages/@react-spectrum/color/src/ColorField.tsx
@@ -73,7 +73,7 @@ function ColorChannelField(props: ColorChannelFieldProps) {
         inputRef={inputRef}
         {...result}
         inputClassName={classNames(styles, 'react-spectrum-ColorField-input')} />
-      {props.name && <input type="hidden" name={props.name} value={isNaN(state.numberValue) ? '' : state.numberValue} />}
+      {props.name && <input type="hidden" name={props.name} form={props.form} value={isNaN(state.numberValue) ? '' : state.numberValue} />}
     </>
   );
 }
diff --git a/packages/@react-spectrum/combobox/src/ComboBox.tsx b/packages/@react-spectrum/combobox/src/ComboBox.tsx
index 41cc76bffaf..16f5255ce83 100644
--- a/packages/@react-spectrum/combobox/src/ComboBox.tsx
+++ b/packages/@react-spectrum/combobox/src/ComboBox.tsx
@@ -175,7 +175,7 @@ const ComboBoxBase = React.forwardRef(function ComboBoxBase(props: SpectrumCombo
           validationState={props.validationState || (isInvalid ? 'invalid' : undefined)}
           ref={inputGroupRef} />
       </Field>
-      {name && formValue === 'key' && <input type="hidden" name={name} value={state.selectedKey ?? ''} />}
+      {name && formValue === 'key' && <input type="hidden" name={name} form={props.form} value={state.selectedKey ?? ''} />}
       <Popover
         state={state}
         UNSAFE_style={style}
diff --git a/packages/react-aria-components/src/ColorField.tsx b/packages/react-aria-components/src/ColorField.tsx
index 3ba163b0031..47a08b64342 100644
--- a/packages/react-aria-components/src/ColorField.tsx
+++ b/packages/react-aria-components/src/ColorField.tsx
@@ -114,7 +114,7 @@ function ColorChannelField(props: ColorChannelFieldProps) {
         errorMessageProps,
         validation
       )}
-      {props.name && <input type="hidden" name={props.name} value={isNaN(state.numberValue) ? '' : state.numberValue} />}
+      {props.name && <input type="hidden" name={props.name} form={props.form} value={isNaN(state.numberValue) ? '' : state.numberValue} />}
     </>
   );
 }
diff --git a/packages/react-aria-components/src/ComboBox.tsx b/packages/react-aria-components/src/ComboBox.tsx
index a3804b1d5a9..4c348f789a8 100644
--- a/packages/react-aria-components/src/ComboBox.tsx
+++ b/packages/react-aria-components/src/ComboBox.tsx
@@ -224,7 +224,7 @@ function ComboBoxInner<T extends object>({props, collection, comboBoxRef: ref}:
         data-disabled={props.isDisabled || undefined}
         data-invalid={validation.isInvalid || undefined}
         data-required={props.isRequired || undefined} />
-      {name && formValue === 'key' && <input type="hidden" name={name} value={state.selectedKey ?? ''} />}
+      {name && formValue === 'key' && <input type="hidden" name={name} form={props.form} value={state.selectedKey ?? ''} />}
     </Provider>
   );
 }