Skip to content
Merged
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
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