Skip to content

Commit

Permalink
Forward ref to FormControl (#1949)
Browse files Browse the repository at this point in the history
* Forward ref to formcontrol

* Document ref prop on FormControl

* Create bright-flowers-itch.md
  • Loading branch information
colebemis authored Mar 10, 2022
1 parent dbc7d22 commit e430bd8
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 155 deletions.
5 changes: 5 additions & 0 deletions .changeset/bright-flowers-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

`FormControl` now accepts a `ref` prop
1 change: 1 addition & 0 deletions docs/content/FormControl.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
<PropsTableRefRow refType="HTMLDivElement" />
<PropsTableSxRow />
</PropsTable>

Expand Down
313 changes: 158 additions & 155 deletions src/FormControl/FormControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,180 +31,183 @@ export interface FormControlContext extends Pick<FormControlProps, 'disabled' |
validationMessageId: string
}

const FormControl = ({children, disabled: disabledProp, id: idProp, required, sx}: FormControlProps) => {
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<HTMLDivElement, FormControlProps>(
({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, <FormControl>`
)
}
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, <FormControl>`
)
}
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, <FormControl>`
)
}
}

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, <FormControl>`
)
}
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, <FormControl>`
)
}
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, <FormControl>`
)
}
}

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
context={{
captionId,
disabled,
id,
required,
validationMessageId
}}
>
{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 ? (
<Box display="flex" alignItems={slots.LeadingVisual ? 'center' : undefined} sx={sx}>
<Box sx={{'> input': {marginLeft: 0, marginRight: 0}}}>
return (
<Slots
context={{
captionId,
disabled,
id,
required,
validationMessageId
}}
>
{slots => {
const isLabelHidden = React.isValidElement(slots.Label) && slots.Label.props.visuallyHidden

return isChoiceInput ? (
<Box ref={ref} display="flex" alignItems={slots.LeadingVisual ? 'center' : undefined} sx={sx}>
<Box sx={{'> 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)
)}
</Box>
{slots.LeadingVisual && (
<Box
color={disabled ? 'fg.muted' : 'fg.default'}
sx={{
'> *': {
minWidth: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'),
minHeight: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'),
fill: 'currentColor'
}
}}
ml={2}
>
{slots.LeadingVisual}
</Box>
)}
{(React.isValidElement(slots.Label) && !slots.Label.props.visuallyHidden) || slots.Caption ? (
<Box display="flex" flexDirection="column" ml={2}>
{slots.Label}
{slots.Caption}
</Box>
) : (
<>
{slots.Label}
{slots.Caption}
</>
)}
</Box>
) : (
<Box
ref={ref}
display="flex"
flexDirection="column"
width="100%"
sx={{...(isLabelHidden ? {'> *: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 && <ValidationAnimationContainer show>{slots.Validation}</ValidationAnimationContainer>}
{slots.Caption}
</Box>
{slots.LeadingVisual && (
<Box
color={disabled ? 'fg.muted' : 'fg.default'}
sx={{
'> *': {
minWidth: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'),
minHeight: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'),
fill: 'currentColor'
}
}}
ml={2}
>
{slots.LeadingVisual}
</Box>
)}
{(React.isValidElement(slots.Label) && !slots.Label.props.visuallyHidden) || slots.Caption ? (
<Box display="flex" flexDirection="column" ml={2}>
{slots.Label}
{slots.Caption}
</Box>
) : (
<>
{slots.Label}
{slots.Caption}
</>
)}
</Box>
) : (
<Box
display="flex"
flexDirection="column"
width="100%"
sx={{...(isLabelHidden ? {'> *: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 && <ValidationAnimationContainer show>{slots.Validation}</ValidationAnimationContainer>}
{slots.Caption}
</Box>
)
}}
</Slots>
)
}
)
}}
</Slots>
)
}
)

export default Object.assign(FormControl, {
Caption: FormControlCaption,
Expand Down

0 comments on commit e430bd8

Please sign in to comment.