diff --git a/.changeset/bright-flowers-itch.md b/.changeset/bright-flowers-itch.md
new file mode 100644
index 00000000000..72ac5afc6c1
--- /dev/null
+++ b/.changeset/bright-flowers-itch.md
@@ -0,0 +1,5 @@
+---
+"@primer/react": patch
+---
+
+`FormControl` now accepts a `ref` prop
diff --git a/docs/content/FormControl.mdx b/docs/content/FormControl.mdx
index 21a5716f048..9bd343de66c 100644
--- a/docs/content/FormControl.mdx
+++ b/docs/content/FormControl.mdx
@@ -293,6 +293,7 @@ The container that handles the layout and passes the relevant IDs and ARIA attri
defaultValue="false"
description="If true, the user must specify a value for the input before the owning form can be submitted"
/>
+
diff --git a/src/FormControl/FormControl.tsx b/src/FormControl/FormControl.tsx
index c1ac5ea74f8..7ed7dac30f6 100644
--- a/src/FormControl/FormControl.tsx
+++ b/src/FormControl/FormControl.tsx
@@ -31,180 +31,183 @@ export interface FormControlContext extends Pick {
- const expectedInputComponents = [Autocomplete, Checkbox, Radio, Select, TextInput, TextInputWithTokens, Textarea]
- const choiceGroupContext = useContext(CheckboxOrRadioGroupContext)
- const disabled = choiceGroupContext?.disabled || disabledProp
- const id = useSSRSafeId(idProp)
- const validationChild = React.Children.toArray(children).find(child =>
- React.isValidElement(child) && child.type === FormControlValidation ? child : null
- )
- const captionChild = React.Children.toArray(children).find(child =>
- React.isValidElement(child) && child.type === FormControlCaption ? child : null
- )
- const labelChild = React.Children.toArray(children).find(
- child => React.isValidElement(child) && child.type === FormControlLabel
- )
- const validationMessageId = validationChild && `${id}-validationMessage`
- const captionId = captionChild && `${id}-caption`
- const validationStatus = React.isValidElement(validationChild) && validationChild.props.variant
- const InputComponent = React.Children.toArray(children).find(child =>
- expectedInputComponents.some(inputComponent => React.isValidElement(child) && child.type === inputComponent)
- )
- const inputProps = React.isValidElement(InputComponent) && InputComponent.props
- const isChoiceInput =
- React.isValidElement(InputComponent) && (InputComponent.type === Checkbox || InputComponent.type === Radio)
-
- if (!InputComponent) {
- // eslint-disable-next-line no-console
- console.warn(
- `To correctly render this field with the correct ARIA attributes passed to the input, please pass one of the component from @primer/react as a direct child of the FormControl component: ${expectedInputComponents.reduce(
- (acc, componentName) => {
- acc += `\n- ${componentName.displayName}`
-
- return acc
- },
- ''
- )}`,
- 'If you are using a custom input component, please be sure to follow WCAG guidelines to make your form control accessible.'
+const FormControl = React.forwardRef(
+ ({children, disabled: disabledProp, id: idProp, required, sx}, ref) => {
+ const expectedInputComponents = [Autocomplete, Checkbox, Radio, Select, TextInput, TextInputWithTokens, Textarea]
+ const choiceGroupContext = useContext(CheckboxOrRadioGroupContext)
+ const disabled = choiceGroupContext?.disabled || disabledProp
+ const id = useSSRSafeId(idProp)
+ const validationChild = React.Children.toArray(children).find(child =>
+ React.isValidElement(child) && child.type === FormControlValidation ? child : null
)
- } else {
- if (inputProps?.id) {
- // eslint-disable-next-line no-console
- console.warn(
- `instead of passing the 'id' prop directly to the input component, it should be passed to the parent component, `
- )
- }
- if (inputProps?.disabled) {
- // eslint-disable-next-line no-console
- console.warn(
- `instead of passing the 'disabled' prop directly to the input component, it should be passed to the parent component, `
- )
- }
- if (inputProps?.required) {
- // eslint-disable-next-line no-console
- console.warn(
- `instead of passing the 'required' prop directly to the input component, it should be passed to the parent component, `
- )
- }
- }
-
- if (!labelChild) {
- // eslint-disable-next-line no-console
- console.error(
- `The input field with the id ${id} MUST have a FormControl.Label child.\n\nIf you want to hide the label, pass the 'visuallyHidden' prop to the FormControl.Label component.`
+ const captionChild = React.Children.toArray(children).find(child =>
+ React.isValidElement(child) && child.type === FormControlCaption ? child : null
)
- }
+ const labelChild = React.Children.toArray(children).find(
+ child => React.isValidElement(child) && child.type === FormControlLabel
+ )
+ const validationMessageId = validationChild && `${id}-validationMessage`
+ const captionId = captionChild && `${id}-caption`
+ const validationStatus = React.isValidElement(validationChild) && validationChild.props.variant
+ const InputComponent = React.Children.toArray(children).find(child =>
+ expectedInputComponents.some(inputComponent => React.isValidElement(child) && child.type === inputComponent)
+ )
+ const inputProps = React.isValidElement(InputComponent) && InputComponent.props
+ const isChoiceInput =
+ React.isValidElement(InputComponent) && (InputComponent.type === Checkbox || InputComponent.type === Radio)
- if (isChoiceInput) {
- if (validationChild) {
+ if (!InputComponent) {
// eslint-disable-next-line no-console
console.warn(
- 'Validation messages are not rendered for an individual checkbox or radio. The validation message should be shown for all options.'
+ `To correctly render this field with the correct ARIA attributes passed to the input, please pass one of the component from @primer/react as a direct child of the FormControl component: ${expectedInputComponents.reduce(
+ (acc, componentName) => {
+ acc += `\n- ${componentName.displayName}`
+
+ return acc
+ },
+ ''
+ )}`,
+ 'If you are using a custom input component, please be sure to follow WCAG guidelines to make your form control accessible.'
)
+ } else {
+ if (inputProps?.id) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `instead of passing the 'id' prop directly to the input component, it should be passed to the parent component, `
+ )
+ }
+ if (inputProps?.disabled) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `instead of passing the 'disabled' prop directly to the input component, it should be passed to the parent component, `
+ )
+ }
+ if (inputProps?.required) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `instead of passing the 'required' prop directly to the input component, it should be passed to the parent component, `
+ )
+ }
}
- if (React.Children.toArray(children).find(child => React.isValidElement(child) && child.props?.required)) {
- // eslint-disable-next-line no-console
- console.warn('An individual checkbox or radio cannot be a required field.')
- }
- } else {
- if (
- React.Children.toArray(children).find(
- child => React.isValidElement(child) && child.type === FormControlLeadingVisual
- )
- ) {
+ if (!labelChild) {
// eslint-disable-next-line no-console
- console.warn(
- 'A leading visual is only rendered for a checkbox or radio form control. If you want to render a leading visual inside of your input, check if your input supports a leading visual.'
+ console.error(
+ `The input field with the id ${id} MUST have a FormControl.Label child.\n\nIf you want to hide the label, pass the 'visuallyHidden' prop to the FormControl.Label component.`
)
}
- }
- return (
-
- {slots => {
- const isLabelHidden = React.isValidElement(slots.Label) && slots.Label.props.visuallyHidden
+ if (isChoiceInput) {
+ if (validationChild) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ 'Validation messages are not rendered for an individual checkbox or radio. The validation message should be shown for all options.'
+ )
+ }
+
+ if (React.Children.toArray(children).find(child => React.isValidElement(child) && child.props?.required)) {
+ // eslint-disable-next-line no-console
+ console.warn('An individual checkbox or radio cannot be a required field.')
+ }
+ } else {
+ if (
+ React.Children.toArray(children).find(
+ child => React.isValidElement(child) && child.type === FormControlLeadingVisual
+ )
+ ) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ 'A leading visual is only rendered for a checkbox or radio form control. If you want to render a leading visual inside of your input, check if your input supports a leading visual.'
+ )
+ }
+ }
- return isChoiceInput ? (
-
- input': {marginLeft: 0, marginRight: 0}}}>
+ return (
+
+ {slots => {
+ const isLabelHidden = React.isValidElement(slots.Label) && slots.Label.props.visuallyHidden
+
+ return isChoiceInput ? (
+
+ input': {marginLeft: 0, marginRight: 0}}}>
+ {React.isValidElement(InputComponent) &&
+ React.cloneElement(InputComponent, {
+ id,
+ disabled,
+ ['aria-describedby']: captionId
+ })}
+ {React.Children.toArray(children).filter(
+ child =>
+ React.isValidElement(child) &&
+ ![Checkbox, Radio].some(inputComponent => child.type === inputComponent)
+ )}
+
+ {slots.LeadingVisual && (
+ *': {
+ minWidth: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'),
+ minHeight: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'),
+ fill: 'currentColor'
+ }
+ }}
+ ml={2}
+ >
+ {slots.LeadingVisual}
+
+ )}
+ {(React.isValidElement(slots.Label) && !slots.Label.props.visuallyHidden) || slots.Caption ? (
+
+ {slots.Label}
+ {slots.Caption}
+
+ ) : (
+ <>
+ {slots.Label}
+ {slots.Caption}
+ >
+ )}
+
+ ) : (
+ *:not(label) + *': {marginTop: 2}} : {'> * + *': {marginTop: 2}}), ...sx}}
+ >
+ {React.Children.toArray(children).filter(
+ child =>
+ React.isValidElement(child) &&
+ !expectedInputComponents.some(inputComponent => child.type === inputComponent)
+ )}
+ {slots.Label}
{React.isValidElement(InputComponent) &&
React.cloneElement(InputComponent, {
id,
+ required,
disabled,
- ['aria-describedby']: captionId
+ validationStatus,
+ ['aria-describedby']: [validationMessageId, captionId].filter(Boolean).join(' ')
})}
- {React.Children.toArray(children).filter(
- child =>
- React.isValidElement(child) &&
- ![Checkbox, Radio].some(inputComponent => child.type === inputComponent)
- )}
+ {validationChild && {slots.Validation}}
+ {slots.Caption}
- {slots.LeadingVisual && (
- *': {
- minWidth: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'),
- minHeight: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'),
- fill: 'currentColor'
- }
- }}
- ml={2}
- >
- {slots.LeadingVisual}
-
- )}
- {(React.isValidElement(slots.Label) && !slots.Label.props.visuallyHidden) || slots.Caption ? (
-
- {slots.Label}
- {slots.Caption}
-
- ) : (
- <>
- {slots.Label}
- {slots.Caption}
- >
- )}
-
- ) : (
- *:not(label) + *': {marginTop: 2}} : {'> * + *': {marginTop: 2}}), ...sx}}
- >
- {React.Children.toArray(children).filter(
- child =>
- React.isValidElement(child) &&
- !expectedInputComponents.some(inputComponent => child.type === inputComponent)
- )}
- {slots.Label}
- {React.isValidElement(InputComponent) &&
- React.cloneElement(InputComponent, {
- id,
- required,
- disabled,
- validationStatus,
- ['aria-describedby']: [validationMessageId, captionId].filter(Boolean).join(' ')
- })}
- {validationChild && {slots.Validation}}
- {slots.Caption}
-
- )
- }}
-
- )
-}
+ )
+ }}
+
+ )
+ }
+)
export default Object.assign(FormControl, {
Caption: FormControlCaption,