Skip to content

Commit

Permalink
Use SSR-compatible slot implementation in FormControl (#3149)
Browse files Browse the repository at this point in the history
* Update slots in FormControl

* Rename rest variable

* Remove old slots

* Fix tests

* Create .changeset/cool-ghosts-remember.md
  • Loading branch information
colebemis authored Apr 11, 2023
1 parent 3fd3f81 commit 4c2d121
Show file tree
Hide file tree
Showing 7 changed files with 168 additions and 178 deletions.
7 changes: 7 additions & 0 deletions .changeset/cool-ghosts-remember.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@primer/react": patch
---

`FormControl` is now SSR-compatible.

Warning: In this new implementation, `FormControl.Caption`, `FormControl.Label`, `FormControl.LeadingVisual`, and `FormControl.Validation` must be direct children of `FormControl`.
222 changes: 108 additions & 114 deletions src/FormControl/FormControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,20 @@ import Box from '../Box'
import Checkbox from '../Checkbox'
import Radio from '../Radio'
import Select from '../Select'
import Textarea from '../Textarea'
import TextInput from '../TextInput'
import TextInputWithTokens from '../TextInputWithTokens'
import {useSSRSafeId} from '../utils/ssr'
import FormControlCaption from './_FormControlCaption'
import FormControlLabel, {Props as FormControlLabelProps} from './_FormControlLabel'
import FormControlValidation from './_FormControlValidation'
import {Slots} from './slots'
import Textarea from '../Textarea'
import {CheckboxOrRadioGroupContext} from '../_CheckboxOrRadioGroup'
import ValidationAnimationContainer from '../_ValidationAnimationContainer'
import {get} from '../constants'
import FormControlLeadingVisual from './_FormControlLeadingVisual'
import {SxProp} from '../sx'
import {CheckboxOrRadioGroupContext} from '../_CheckboxOrRadioGroup'
import InlineAutocomplete from '../drafts/InlineAutocomplete'
import {useSlots} from '../hooks/useSlots'
import {SxProp} from '../sx'
import {useSSRSafeId} from '../utils/ssr'
import FormControlCaption from './_FormControlCaption'
import FormControlLabel from './_FormControlLabel'
import FormControlLeadingVisual from './_FormControlLeadingVisual'
import FormControlValidation from './_FormControlValidation'

export type FormControlProps = {
children?: React.ReactNode
Expand All @@ -41,12 +41,20 @@ export type FormControlProps = {
} & SxProp

export interface FormControlContext extends Pick<FormControlProps, 'disabled' | 'id' | 'required'> {
captionId: string
validationMessageId: string
captionId?: string
validationMessageId?: string
}

export const FormControlContext = React.createContext<FormControlContext>({})

const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
({children, disabled: disabledProp, layout = 'vertical', id: idProp, required, sx}, ref) => {
const [slots, childrenWithoutSlots] = useSlots(children, {
caption: FormControlCaption,
label: FormControlLabel,
leadingVisual: FormControlLeadingVisual,
validation: FormControlValidation,
})
const expectedInputComponents = [
Autocomplete,
Checkbox,
Expand All @@ -60,19 +68,10 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
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 =>
const validationMessageId = slots.validation ? `${id}-validationMessage` : undefined
const captionId = slots.caption ? `${id}-caption` : undefined
const validationStatus = slots.validation?.props.variant
const InputComponent = childrenWithoutSlots.find(child =>
expectedInputComponents.some(inputComponent => React.isValidElement(child) && child.type === inputComponent),
)
const inputProps = React.isValidElement(InputComponent) && InputComponent.props
Expand Down Expand Up @@ -100,135 +99,130 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>(
}
}

if (!labelChild) {
if (!slots.label) {
// 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.`,
)
}

if (isChoiceInput) {
if (validationChild) {
if (slots.validation) {
// 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)) {
if (childrenWithoutSlots.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 (slots.leadingVisual) {
// 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.',
)
}
}

const isLabelHidden = slots.label?.props.visuallyHidden

return (
<Slots
context={{
<FormControlContext.Provider
value={{
captionId,
disabled,
id,
required,
validationMessageId,
}}
>
{slots => {
const isLabelHidden = React.isValidElement(slots.Label) && slots.Label.props.visuallyHidden

return isChoiceInput || layout === 'horizontal' ? (
<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 as React.ReactElement<{
id: string
disabled: boolean
['aria-describedby']: string
}>,
{
id,
disabled,
['aria-describedby']: captionId as string,
},
)}
{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 as FormControlLabelProps).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"
alignItems="flex-start"
sx={{...(isLabelHidden ? {'> *:not(label) + *': {marginTop: 1}} : {'> * + *': {marginTop: 1}}), ...sx}}
>
{slots.Label}
{isChoiceInput || layout === 'horizontal' ? (
<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,
Object.assign(
{
id,
required,
disabled,
validationStatus,
['aria-describedby']: [validationMessageId, captionId].filter(Boolean).join(' '),
},
InputComponent.props,
),
InputComponent as React.ReactElement<{
id: string
disabled: boolean
['aria-describedby']: string
}>,
{
id,
disabled,
['aria-describedby']: captionId as string,
},
)}
{React.Children.toArray(children).filter(
{childrenWithoutSlots.filter(
child =>
React.isValidElement(child) &&
!expectedInputComponents.some(inputComponent => child.type === inputComponent),
![Checkbox, Radio].some(inputComponent => child.type === inputComponent),
)}
{validationChild && <ValidationAnimationContainer show>{slots.Validation}</ValidationAnimationContainer>}
{slots.Caption}
</Box>
)
}}
</Slots>
{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>
)}
{!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"
alignItems="flex-start"
sx={{...(isLabelHidden ? {'> *:not(label) + *': {marginTop: 1}} : {'> * + *': {marginTop: 1}}), ...sx}}
>
{slots.label}
{React.isValidElement(InputComponent) &&
React.cloneElement(
InputComponent,
Object.assign(
{
id,
required,
disabled,
validationStatus,
['aria-describedby']: [validationMessageId, captionId].filter(Boolean).join(' '),
},
InputComponent.props,
),
)}
{childrenWithoutSlots.filter(
child =>
React.isValidElement(child) &&
!expectedInputComponents.some(inputComponent => child.type === inputComponent),
)}
{slots.validation ? (
<ValidationAnimationContainer show>{slots.validation}</ValidationAnimationContainer>
) : null}
{slots.caption}
</Box>
)}
</FormControlContext.Provider>
)
},
)
Expand Down
20 changes: 9 additions & 11 deletions src/FormControl/_FormControlCaption.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
import React from 'react'
import {SxProp} from '../sx'
import InputCaption from '../_InputCaption'
import {SxProp} from '../sx'
import {FormControlContext} from './FormControl'
import {Slot} from './slots'

const FormControlCaption: React.FC<React.PropsWithChildren<{id?: string} & SxProp>> = ({children, sx, id}) => (
<Slot name="Caption">
{({captionId, disabled}: FormControlContext) => (
<InputCaption id={id || captionId} disabled={disabled} sx={sx}>
{children}
</InputCaption>
)}
</Slot>
)
const FormControlCaption: React.FC<React.PropsWithChildren<{id?: string} & SxProp>> = ({children, sx, id}) => {
const {captionId, disabled} = React.useContext(FormControlContext)
return (
<InputCaption id={id || captionId || ''} disabled={disabled} sx={sx}>
{children}
</InputCaption>
)
}

export default FormControlCaption
34 changes: 16 additions & 18 deletions src/FormControl/_FormControlLabel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React from 'react'
import InputLabel, {LabelProps, LegendOrSpanProps} from '../_InputLabel'
import {SxProp} from '../sx'
import InputLabel, {LegendOrSpanProps, LabelProps} from '../_InputLabel'
import {FormControlContext} from './FormControl'
import {Slot} from './slots'

export type Props = {
/**
Expand All @@ -14,21 +13,20 @@ export type Props = {

const FormControlLabel: React.FC<
React.PropsWithChildren<{htmlFor?: string} & (LegendOrSpanProps | LabelProps) & Props>
> = ({children, htmlFor, id, visuallyHidden, sx}) => (
<Slot name="Label">
{({disabled, id: formControlId, required}: FormControlContext) => (
<InputLabel
htmlFor={htmlFor || formControlId}
id={id}
visuallyHidden={visuallyHidden}
required={required}
disabled={disabled}
sx={sx}
>
{children}
</InputLabel>
)}
</Slot>
)
> = ({children, htmlFor, id, visuallyHidden, sx}) => {
const {disabled, id: formControlId, required} = React.useContext(FormControlContext)
return (
<InputLabel
htmlFor={htmlFor || formControlId}
id={id}
visuallyHidden={visuallyHidden}
required={required}
disabled={disabled}
sx={sx}
>
{children}
</InputLabel>
)
}

export default FormControlLabel
Loading

0 comments on commit 4c2d121

Please sign in to comment.