Skip to content

feat: Support associating components with external forms #8411

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/@react-aria/button/src/useButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
3 changes: 2 additions & 1 deletion packages/@react-aria/checkbox/src/useCheckboxGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -50,6 +50,7 @@ export function useCheckboxGroup(props: AriaCheckboxGroupProps, state: CheckboxG

checkboxGroupData.set(state, {
name,
form,
descriptionId: descriptionProps.id,
errorMessageId: errorMessageProps.id,
validationBehavior
Expand Down
3 changes: 2 additions & 1 deletion packages/@react-aria/checkbox/src/useCheckboxGroupItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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]: {
Expand Down
1 change: 1 addition & 0 deletions packages/@react-aria/checkbox/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {CheckboxGroupState} from '@react-stately/checkbox';

interface CheckboxGroupData {
name?: string,
form?: string,
descriptionId?: string,
errorMessageId?: string,
validationBehavior: 'aria' | 'native'
Expand Down
5 changes: 4 additions & 1 deletion packages/@react-aria/color/src/useColorArea.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/@react-aria/color/src/useColorSlider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -60,6 +60,7 @@ export function useColorSlider(props: AriaColorSliderOptions, state: ColorSlider
orientation,
isDisabled: props.isDisabled,
name,
form,
trackRef,
inputRef
}, state);
Expand Down
4 changes: 3 additions & 1 deletion packages/@react-aria/color/src/useColorWheel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ export function useColorWheel(props: AriaColorWheelOptions, state: ColorWheelSta
innerRadius,
outerRadius,
'aria-label': ariaLabel,
name
name,
form
} = props;

let {addGlobalListener, removeGlobalListener} = useGlobalListeners();
Expand Down Expand Up @@ -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));
},
Expand Down
1 change: 1 addition & 0 deletions packages/@react-aria/datepicker/src/useDateField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand Down
3 changes: 2 additions & 1 deletion packages/@react-aria/datepicker/src/useDatePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/@react-aria/datepicker/src/useDateRangePicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/@react-aria/numberfield/src/useNumberField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/@react-aria/radio/src/useRadio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/@react-aria/radio/src/useRadioGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export interface RadioGroupAria extends ValidationResult {
export function useRadioGroup(props: AriaRadioGroupProps, state: RadioGroupState): RadioGroupAria {
let {
name,
form,
isReadOnly,
isRequired,
isDisabled,
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/@react-aria/radio/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
13 changes: 11 additions & 2 deletions packages/@react-aria/select/src/HiddenSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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)
}
Expand All @@ -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);

Expand Down Expand Up @@ -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 ?? ''} />
);
Expand Down
3 changes: 3 additions & 0 deletions packages/@react-aria/select/src/useSelect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface SelectData {
isDisabled?: boolean,
isRequired?: boolean,
name?: string,
form?: string,
validationBehavior?: 'aria' | 'native'
}

Expand All @@ -72,6 +73,7 @@ export function useSelect<T>(props: AriaSelectOptions<T>, state: SelectState<T>,
isDisabled,
isRequired,
name,
form,
validationBehavior = 'aria'
} = props;

Expand Down Expand Up @@ -142,6 +144,7 @@ export function useSelect<T>(props: AriaSelectOptions<T>, state: SelectState<T>,
isDisabled,
isRequired,
name,
form,
validationBehavior
});

Expand Down
4 changes: 3 additions & 1 deletion packages/@react-aria/slider/src/useSliderThumb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ export function useSliderThumb(
trackRef,
inputRef,
orientation = state.orientation,
name
name,
form
} = opts;

let isDisabled = opts.isDisabled || state.isDisabled;
Expand Down Expand Up @@ -244,6 +245,7 @@ export function useSliderThumb(
step: state.step,
value: value,
name,
form,
disabled: isDisabled,
'aria-orientation': orientation,
'aria-valuetext': state.getThumbValueLabel(index),
Expand Down
1 change: 1 addition & 0 deletions packages/@react-aria/textfield/src/useTextField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/@react-aria/toggle/src/useToggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -94,6 +95,7 @@ export function useToggle(props: AriaToggleProps, state: ToggleState, ref: RefOb
disabled: isDisabled,
...(value == null ? {} : {value}),
name,
form,
type: 'checkbox',
...interactions
}),
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/numberfield/src/NumberField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
4 changes: 3 additions & 1 deletion packages/@react-spectrum/picker/src/Picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const Picker = React.forwardRef(function Picker<T extends object>(props:
labelPosition = 'top' as LabelPosition,
menuWidth,
name,
form,
autoFocus
} = props;

Expand Down Expand Up @@ -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}
Expand Down
15 changes: 15 additions & 0 deletions packages/@react-spectrum/picker/test/Picker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading