Skip to content

Commit e430bd8

Browse files
authored
Forward ref to FormControl (#1949)
* Forward ref to formcontrol * Document ref prop on FormControl * Create bright-flowers-itch.md
1 parent dbc7d22 commit e430bd8

File tree

3 files changed

+164
-155
lines changed

3 files changed

+164
-155
lines changed

.changeset/bright-flowers-itch.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": patch
3+
---
4+
5+
`FormControl` now accepts a `ref` prop

docs/content/FormControl.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ The container that handles the layout and passes the relevant IDs and ARIA attri
293293
defaultValue="false"
294294
description="If true, the user must specify a value for the input before the owning form can be submitted"
295295
/>
296+
<PropsTableRefRow refType="HTMLDivElement" />
296297
<PropsTableSxRow />
297298
</PropsTable>
298299

src/FormControl/FormControl.tsx

Lines changed: 158 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -31,180 +31,183 @@ export interface FormControlContext extends Pick<FormControlProps, 'disabled' |
3131
validationMessageId: string
3232
}
3333

34-
const FormControl = ({children, disabled: disabledProp, id: idProp, required, sx}: FormControlProps) => {
35-
const expectedInputComponents = [Autocomplete, Checkbox, Radio, Select, TextInput, TextInputWithTokens, Textarea]
36-
const choiceGroupContext = useContext(CheckboxOrRadioGroupContext)
37-
const disabled = choiceGroupContext?.disabled || disabledProp
38-
const id = useSSRSafeId(idProp)
39-
const validationChild = React.Children.toArray(children).find(child =>
40-
React.isValidElement(child) && child.type === FormControlValidation ? child : null
41-
)
42-
const captionChild = React.Children.toArray(children).find(child =>
43-
React.isValidElement(child) && child.type === FormControlCaption ? child : null
44-
)
45-
const labelChild = React.Children.toArray(children).find(
46-
child => React.isValidElement(child) && child.type === FormControlLabel
47-
)
48-
const validationMessageId = validationChild && `${id}-validationMessage`
49-
const captionId = captionChild && `${id}-caption`
50-
const validationStatus = React.isValidElement(validationChild) && validationChild.props.variant
51-
const InputComponent = React.Children.toArray(children).find(child =>
52-
expectedInputComponents.some(inputComponent => React.isValidElement(child) && child.type === inputComponent)
53-
)
54-
const inputProps = React.isValidElement(InputComponent) && InputComponent.props
55-
const isChoiceInput =
56-
React.isValidElement(InputComponent) && (InputComponent.type === Checkbox || InputComponent.type === Radio)
57-
58-
if (!InputComponent) {
59-
// eslint-disable-next-line no-console
60-
console.warn(
61-
`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(
62-
(acc, componentName) => {
63-
acc += `\n- ${componentName.displayName}`
64-
65-
return acc
66-
},
67-
''
68-
)}`,
69-
'If you are using a custom input component, please be sure to follow WCAG guidelines to make your form control accessible.'
34+
const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
35+
({children, disabled: disabledProp, id: idProp, required, sx}, ref) => {
36+
const expectedInputComponents = [Autocomplete, Checkbox, Radio, Select, TextInput, TextInputWithTokens, Textarea]
37+
const choiceGroupContext = useContext(CheckboxOrRadioGroupContext)
38+
const disabled = choiceGroupContext?.disabled || disabledProp
39+
const id = useSSRSafeId(idProp)
40+
const validationChild = React.Children.toArray(children).find(child =>
41+
React.isValidElement(child) && child.type === FormControlValidation ? child : null
7042
)
71-
} else {
72-
if (inputProps?.id) {
73-
// eslint-disable-next-line no-console
74-
console.warn(
75-
`instead of passing the 'id' prop directly to the input component, it should be passed to the parent component, <FormControl>`
76-
)
77-
}
78-
if (inputProps?.disabled) {
79-
// eslint-disable-next-line no-console
80-
console.warn(
81-
`instead of passing the 'disabled' prop directly to the input component, it should be passed to the parent component, <FormControl>`
82-
)
83-
}
84-
if (inputProps?.required) {
85-
// eslint-disable-next-line no-console
86-
console.warn(
87-
`instead of passing the 'required' prop directly to the input component, it should be passed to the parent component, <FormControl>`
88-
)
89-
}
90-
}
91-
92-
if (!labelChild) {
93-
// eslint-disable-next-line no-console
94-
console.error(
95-
`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.`
43+
const captionChild = React.Children.toArray(children).find(child =>
44+
React.isValidElement(child) && child.type === FormControlCaption ? child : null
9645
)
97-
}
46+
const labelChild = React.Children.toArray(children).find(
47+
child => React.isValidElement(child) && child.type === FormControlLabel
48+
)
49+
const validationMessageId = validationChild && `${id}-validationMessage`
50+
const captionId = captionChild && `${id}-caption`
51+
const validationStatus = React.isValidElement(validationChild) && validationChild.props.variant
52+
const InputComponent = React.Children.toArray(children).find(child =>
53+
expectedInputComponents.some(inputComponent => React.isValidElement(child) && child.type === inputComponent)
54+
)
55+
const inputProps = React.isValidElement(InputComponent) && InputComponent.props
56+
const isChoiceInput =
57+
React.isValidElement(InputComponent) && (InputComponent.type === Checkbox || InputComponent.type === Radio)
9858

99-
if (isChoiceInput) {
100-
if (validationChild) {
59+
if (!InputComponent) {
10160
// eslint-disable-next-line no-console
10261
console.warn(
103-
'Validation messages are not rendered for an individual checkbox or radio. The validation message should be shown for all options.'
62+
`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(
63+
(acc, componentName) => {
64+
acc += `\n- ${componentName.displayName}`
65+
66+
return acc
67+
},
68+
''
69+
)}`,
70+
'If you are using a custom input component, please be sure to follow WCAG guidelines to make your form control accessible.'
10471
)
72+
} else {
73+
if (inputProps?.id) {
74+
// eslint-disable-next-line no-console
75+
console.warn(
76+
`instead of passing the 'id' prop directly to the input component, it should be passed to the parent component, <FormControl>`
77+
)
78+
}
79+
if (inputProps?.disabled) {
80+
// eslint-disable-next-line no-console
81+
console.warn(
82+
`instead of passing the 'disabled' prop directly to the input component, it should be passed to the parent component, <FormControl>`
83+
)
84+
}
85+
if (inputProps?.required) {
86+
// eslint-disable-next-line no-console
87+
console.warn(
88+
`instead of passing the 'required' prop directly to the input component, it should be passed to the parent component, <FormControl>`
89+
)
90+
}
10591
}
10692

107-
if (React.Children.toArray(children).find(child => React.isValidElement(child) && child.props?.required)) {
108-
// eslint-disable-next-line no-console
109-
console.warn('An individual checkbox or radio cannot be a required field.')
110-
}
111-
} else {
112-
if (
113-
React.Children.toArray(children).find(
114-
child => React.isValidElement(child) && child.type === FormControlLeadingVisual
115-
)
116-
) {
93+
if (!labelChild) {
11794
// eslint-disable-next-line no-console
118-
console.warn(
119-
'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.'
95+
console.error(
96+
`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.`
12097
)
12198
}
122-
}
12399

124-
return (
125-
<Slots
126-
context={{
127-
captionId,
128-
disabled,
129-
id,
130-
required,
131-
validationMessageId
132-
}}
133-
>
134-
{slots => {
135-
const isLabelHidden = React.isValidElement(slots.Label) && slots.Label.props.visuallyHidden
100+
if (isChoiceInput) {
101+
if (validationChild) {
102+
// eslint-disable-next-line no-console
103+
console.warn(
104+
'Validation messages are not rendered for an individual checkbox or radio. The validation message should be shown for all options.'
105+
)
106+
}
107+
108+
if (React.Children.toArray(children).find(child => React.isValidElement(child) && child.props?.required)) {
109+
// eslint-disable-next-line no-console
110+
console.warn('An individual checkbox or radio cannot be a required field.')
111+
}
112+
} else {
113+
if (
114+
React.Children.toArray(children).find(
115+
child => React.isValidElement(child) && child.type === FormControlLeadingVisual
116+
)
117+
) {
118+
// eslint-disable-next-line no-console
119+
console.warn(
120+
'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.'
121+
)
122+
}
123+
}
136124

137-
return isChoiceInput ? (
138-
<Box display="flex" alignItems={slots.LeadingVisual ? 'center' : undefined} sx={sx}>
139-
<Box sx={{'> input': {marginLeft: 0, marginRight: 0}}}>
125+
return (
126+
<Slots
127+
context={{
128+
captionId,
129+
disabled,
130+
id,
131+
required,
132+
validationMessageId
133+
}}
134+
>
135+
{slots => {
136+
const isLabelHidden = React.isValidElement(slots.Label) && slots.Label.props.visuallyHidden
137+
138+
return isChoiceInput ? (
139+
<Box ref={ref} display="flex" alignItems={slots.LeadingVisual ? 'center' : undefined} sx={sx}>
140+
<Box sx={{'> input': {marginLeft: 0, marginRight: 0}}}>
141+
{React.isValidElement(InputComponent) &&
142+
React.cloneElement(InputComponent, {
143+
id,
144+
disabled,
145+
['aria-describedby']: captionId
146+
})}
147+
{React.Children.toArray(children).filter(
148+
child =>
149+
React.isValidElement(child) &&
150+
![Checkbox, Radio].some(inputComponent => child.type === inputComponent)
151+
)}
152+
</Box>
153+
{slots.LeadingVisual && (
154+
<Box
155+
color={disabled ? 'fg.muted' : 'fg.default'}
156+
sx={{
157+
'> *': {
158+
minWidth: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'),
159+
minHeight: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'),
160+
fill: 'currentColor'
161+
}
162+
}}
163+
ml={2}
164+
>
165+
{slots.LeadingVisual}
166+
</Box>
167+
)}
168+
{(React.isValidElement(slots.Label) && !slots.Label.props.visuallyHidden) || slots.Caption ? (
169+
<Box display="flex" flexDirection="column" ml={2}>
170+
{slots.Label}
171+
{slots.Caption}
172+
</Box>
173+
) : (
174+
<>
175+
{slots.Label}
176+
{slots.Caption}
177+
</>
178+
)}
179+
</Box>
180+
) : (
181+
<Box
182+
ref={ref}
183+
display="flex"
184+
flexDirection="column"
185+
width="100%"
186+
sx={{...(isLabelHidden ? {'> *:not(label) + *': {marginTop: 2}} : {'> * + *': {marginTop: 2}}), ...sx}}
187+
>
188+
{React.Children.toArray(children).filter(
189+
child =>
190+
React.isValidElement(child) &&
191+
!expectedInputComponents.some(inputComponent => child.type === inputComponent)
192+
)}
193+
{slots.Label}
140194
{React.isValidElement(InputComponent) &&
141195
React.cloneElement(InputComponent, {
142196
id,
197+
required,
143198
disabled,
144-
['aria-describedby']: captionId
199+
validationStatus,
200+
['aria-describedby']: [validationMessageId, captionId].filter(Boolean).join(' ')
145201
})}
146-
{React.Children.toArray(children).filter(
147-
child =>
148-
React.isValidElement(child) &&
149-
![Checkbox, Radio].some(inputComponent => child.type === inputComponent)
150-
)}
202+
{validationChild && <ValidationAnimationContainer show>{slots.Validation}</ValidationAnimationContainer>}
203+
{slots.Caption}
151204
</Box>
152-
{slots.LeadingVisual && (
153-
<Box
154-
color={disabled ? 'fg.muted' : 'fg.default'}
155-
sx={{
156-
'> *': {
157-
minWidth: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'),
158-
minHeight: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'),
159-
fill: 'currentColor'
160-
}
161-
}}
162-
ml={2}
163-
>
164-
{slots.LeadingVisual}
165-
</Box>
166-
)}
167-
{(React.isValidElement(slots.Label) && !slots.Label.props.visuallyHidden) || slots.Caption ? (
168-
<Box display="flex" flexDirection="column" ml={2}>
169-
{slots.Label}
170-
{slots.Caption}
171-
</Box>
172-
) : (
173-
<>
174-
{slots.Label}
175-
{slots.Caption}
176-
</>
177-
)}
178-
</Box>
179-
) : (
180-
<Box
181-
display="flex"
182-
flexDirection="column"
183-
width="100%"
184-
sx={{...(isLabelHidden ? {'> *:not(label) + *': {marginTop: 2}} : {'> * + *': {marginTop: 2}}), ...sx}}
185-
>
186-
{React.Children.toArray(children).filter(
187-
child =>
188-
React.isValidElement(child) &&
189-
!expectedInputComponents.some(inputComponent => child.type === inputComponent)
190-
)}
191-
{slots.Label}
192-
{React.isValidElement(InputComponent) &&
193-
React.cloneElement(InputComponent, {
194-
id,
195-
required,
196-
disabled,
197-
validationStatus,
198-
['aria-describedby']: [validationMessageId, captionId].filter(Boolean).join(' ')
199-
})}
200-
{validationChild && <ValidationAnimationContainer show>{slots.Validation}</ValidationAnimationContainer>}
201-
{slots.Caption}
202-
</Box>
203-
)
204-
}}
205-
</Slots>
206-
)
207-
}
205+
)
206+
}}
207+
</Slots>
208+
)
209+
}
210+
)
208211

209212
export default Object.assign(FormControl, {
210213
Caption: FormControlCaption,

0 commit comments

Comments
 (0)