Skip to content

Use SSR-compatible slot implementation in FormControl #3149

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Apr 11, 2023
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
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