From 5f9f1cd36a20b417ae96f86d0f0634972f10d490 Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Mon, 10 Apr 2023 19:50:43 -0600 Subject: [PATCH 1/5] Update slots in FormControl --- src/FormControl/FormControl.tsx | 219 +++++++++--------- src/FormControl/_FormControlCaption.tsx | 20 +- src/FormControl/_FormControlLabel.tsx | 34 ++- src/FormControl/_FormControlLeadingVisual.tsx | 40 ++-- src/FormControl/_FormControlValidation.tsx | 20 +- 5 files changed, 160 insertions(+), 173 deletions(-) diff --git a/src/FormControl/FormControl.tsx b/src/FormControl/FormControl.tsx index 41c2056ecf9..ac5e8f3cee8 100644 --- a/src/FormControl/FormControl.tsx +++ b/src/FormControl/FormControl.tsx @@ -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 Textarea from '../Textarea' +import CheckboxOrRadioGroupContext from '../_CheckboxOrRadioGroup/_CheckboxOrRadioGroupContext' +import ValidationAnimationContainer from '../_ValidationAnimationContainer' +import {get} from '../constants' +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, {Props as FormControlLabelProps} from './_FormControlLabel' -import FormControlValidation from './_FormControlValidation' -import {Slots} from './slots' -import ValidationAnimationContainer from '../_ValidationAnimationContainer' -import {get} from '../constants' import FormControlLeadingVisual from './_FormControlLeadingVisual' -import {SxProp} from '../sx' -import CheckboxOrRadioGroupContext from '../_CheckboxOrRadioGroup/_CheckboxOrRadioGroupContext' -import InlineAutocomplete from '../drafts/InlineAutocomplete' +import FormControlValidation from './_FormControlValidation' export type FormControlProps = { children?: React.ReactNode @@ -41,12 +41,20 @@ export type FormControlProps = { } & SxProp export interface FormControlContext extends Pick { - captionId: string - validationMessageId: string + captionId?: string + validationMessageId?: string } +export const FormControlContext = React.createContext({}) + const FormControl = React.forwardRef( ({children, disabled: disabledProp, layout = 'vertical', id: idProp, required, sx}, ref) => { + const [slots, rest] = useSlots(children, { + caption: FormControlCaption, + label: FormControlLabel, + leadingVisual: FormControlLeadingVisual, + validation: FormControlValidation, + }) const expectedInputComponents = [ Autocomplete, Checkbox, @@ -60,18 +68,9 @@ const FormControl = React.forwardRef( 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 validationMessageId = slots.validation ? `${id}-validationMessage` : undefined + const captionId = slots.caption ? `${id}-caption` : undefined + const validationStatus = slots.validation.props.variant const InputComponent = React.Children.toArray(children).find(child => expectedInputComponents.some(inputComponent => React.isValidElement(child) && child.type === inputComponent), ) @@ -100,7 +99,7 @@ const FormControl = React.forwardRef( } } - 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.`, @@ -108,23 +107,19 @@ const FormControl = React.forwardRef( } 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 (rest.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.', @@ -132,9 +127,11 @@ const FormControl = React.forwardRef( } } + const isLabelHidden = React.isValidElement(slots.label) && slots.label.props.visuallyHidden + return ( - ( validationMessageId, }} > - {slots => { - const isLabelHidden = React.isValidElement(slots.Label) && slots.Label.props.visuallyHidden - - return isChoiceInput || layout === 'horizontal' ? ( - - 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), - )} - - {slots.LeadingVisual && ( - *': { - minWidth: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'), - minHeight: slots.Caption ? get('fontSizes.4') : get('fontSizes.2'), - fill: 'currentColor', - }, - }} - ml={2} - > - {slots.LeadingVisual} - - )} - {(React.isValidElement(slots.Label) && !(slots.Label.props as FormControlLabelProps).visuallyHidden) || - slots.Caption ? ( - - {slots.Label} - {slots.Caption} - - ) : ( - <> - {slots.Label} - {slots.Caption} - - )} - - ) : ( - *:not(label) + *': {marginTop: 1}} : {'> * + *': {marginTop: 1}}), ...sx}} - > - {slots.Label} + {isChoiceInput || layout === 'horizontal' ? ( + + 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( + {rest.filter( child => React.isValidElement(child) && - !expectedInputComponents.some(inputComponent => child.type === inputComponent), + ![Checkbox, Radio].some(inputComponent => child.type === inputComponent), )} - {validationChild && {slots.Validation}} - {slots.Caption} - ) - }} - + {slots.leadingVisual && ( + *': { + minWidth: slots.caption ? get('fontSizes.4') : get('fontSizes.2'), + minHeight: slots.caption ? get('fontSizes.4') : get('fontSizes.2'), + fill: 'currentColor', + }, + }} + ml={2} + > + {slots.leadingVisual} + + )} + {(React.isValidElement(slots.label) && !(slots.label.props as FormControlLabelProps).visuallyHidden) || + slots.caption ? ( + + {slots.label} + {slots.caption} + + ) : ( + <> + {slots.label} + {slots.caption} + + )} + + ) : ( + *: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, + ), + )} + {React.Children.toArray(children).filter( + child => + React.isValidElement(child) && + !expectedInputComponents.some(inputComponent => child.type === inputComponent), + )} + {slots.validation ? ( + {slots.validation} + ) : null} + {slots.caption} + + )} + ) }, ) diff --git a/src/FormControl/_FormControlCaption.tsx b/src/FormControl/_FormControlCaption.tsx index e26b95cd5cc..f3ed1767de5 100644 --- a/src/FormControl/_FormControlCaption.tsx +++ b/src/FormControl/_FormControlCaption.tsx @@ -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> = ({children, sx, id}) => ( - - {({captionId, disabled}: FormControlContext) => ( - - {children} - - )} - -) +const FormControlCaption: React.FC> = ({children, sx, id}) => { + const {captionId, disabled} = React.useContext(FormControlContext) + return ( + + {children} + + ) +} export default FormControlCaption diff --git a/src/FormControl/_FormControlLabel.tsx b/src/FormControl/_FormControlLabel.tsx index 9ae1e7d3bf4..bf2675b6479 100644 --- a/src/FormControl/_FormControlLabel.tsx +++ b/src/FormControl/_FormControlLabel.tsx @@ -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 = { /** @@ -14,21 +13,20 @@ export type Props = { const FormControlLabel: React.FC< React.PropsWithChildren<{htmlFor?: string} & (LegendOrSpanProps | LabelProps) & Props> -> = ({children, htmlFor, id, visuallyHidden, sx}) => ( - - {({disabled, id: formControlId, required}: FormControlContext) => ( - - {children} - - )} - -) +> = ({children, htmlFor, id, visuallyHidden, sx}) => { + const {disabled, id: formControlId, required} = React.useContext(FormControlContext) + return ( + + {children} + + ) +} export default FormControlLabel diff --git a/src/FormControl/_FormControlLeadingVisual.tsx b/src/FormControl/_FormControlLeadingVisual.tsx index 806a31d3e92..9e103164499 100644 --- a/src/FormControl/_FormControlLeadingVisual.tsx +++ b/src/FormControl/_FormControlLeadingVisual.tsx @@ -3,27 +3,25 @@ import Box from '../Box' import {get} from '../constants' import {SxProp} from '../sx' import {FormControlContext} from './FormControl' -import {Slot} from './slots' -const FormControlLeadingVisual: React.FC> = ({children, sx}) => ( - - {({disabled, captionId}: FormControlContext) => ( - *': { - minWidth: captionId ? get('fontSizes.4') : get('fontSizes.2'), - minHeight: captionId ? get('fontSizes.4') : get('fontSizes.2'), - fill: 'currentColor', - }, - ...sx, - }} - ml={2} - > - {children} - - )} - -) +const FormControlLeadingVisual: React.FC> = ({children, sx}) => { + const {disabled, captionId} = React.useContext(FormControlContext) + return ( + *': { + minWidth: captionId ? get('fontSizes.4') : get('fontSizes.2'), + minHeight: captionId ? get('fontSizes.4') : get('fontSizes.2'), + fill: 'currentColor', + }, + ...sx, + }} + ml={2} + > + {children} + + ) +} export default FormControlLeadingVisual diff --git a/src/FormControl/_FormControlValidation.tsx b/src/FormControl/_FormControlValidation.tsx index d834cf5d9a3..20d4de1af71 100644 --- a/src/FormControl/_FormControlValidation.tsx +++ b/src/FormControl/_FormControlValidation.tsx @@ -1,9 +1,8 @@ import React from 'react' +import InputValidation from '../_InputValidation' import {SxProp} from '../sx' import {FormValidationStatus} from '../utils/types/FormValidationStatus' -import InputValidation from '../_InputValidation' import {FormControlContext} from './FormControl' -import {Slot} from './slots' export type FormControlValidationProps = { variant: FormValidationStatus @@ -15,14 +14,13 @@ const FormControlValidation: React.FC ( - - {({validationMessageId}: FormControlContext) => ( - - {children} - - )} - -) +}) => { + const {validationMessageId} = React.useContext(FormControlContext) + return ( + + {children} + + ) +} export default FormControlValidation From 39758a7b809a75cd7fb2a27781833f6fbe14f381 Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Mon, 10 Apr 2023 19:55:44 -0600 Subject: [PATCH 2/5] Rename rest variable --- src/FormControl/FormControl.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/FormControl/FormControl.tsx b/src/FormControl/FormControl.tsx index 205e0c1a42b..5efcb21bdcc 100644 --- a/src/FormControl/FormControl.tsx +++ b/src/FormControl/FormControl.tsx @@ -49,7 +49,7 @@ export const FormControlContext = React.createContext({}) const FormControl = React.forwardRef( ({children, disabled: disabledProp, layout = 'vertical', id: idProp, required, sx}, ref) => { - const [slots, rest] = useSlots(children, { + const [slots, childrenWithoutSlots] = useSlots(children, { caption: FormControlCaption, label: FormControlLabel, leadingVisual: FormControlLeadingVisual, @@ -71,7 +71,7 @@ const FormControl = React.forwardRef( const validationMessageId = slots.validation ? `${id}-validationMessage` : undefined const captionId = slots.caption ? `${id}-caption` : undefined const validationStatus = slots.validation?.props.variant - const InputComponent = React.Children.toArray(children).find(child => + const InputComponent = childrenWithoutSlots.find(child => expectedInputComponents.some(inputComponent => React.isValidElement(child) && child.type === inputComponent), ) const inputProps = React.isValidElement(InputComponent) && InputComponent.props @@ -114,7 +114,7 @@ const FormControl = React.forwardRef( ) } - if (rest.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.') } @@ -127,7 +127,7 @@ const FormControl = React.forwardRef( } } - const isLabelHidden = React.isValidElement(slots.label) && slots.label.props.visuallyHidden + const isLabelHidden = slots.label?.props.visuallyHidden return ( ( ['aria-describedby']: captionId as string, }, )} - {rest.filter( + {childrenWithoutSlots.filter( child => React.isValidElement(child) && ![Checkbox, Radio].some(inputComponent => child.type === inputComponent), From d76e0c052ee8dbee2951db9f1cee7fe3703d6aad Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Mon, 10 Apr 2023 19:56:45 -0600 Subject: [PATCH 3/5] Remove old slots --- src/FormControl/slots.ts | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 src/FormControl/slots.ts diff --git a/src/FormControl/slots.ts b/src/FormControl/slots.ts deleted file mode 100644 index 8eb67996d6c..00000000000 --- a/src/FormControl/slots.ts +++ /dev/null @@ -1,3 +0,0 @@ -import createSlots from '../utils/create-slots' - -export const {Slots, Slot} = createSlots(['Caption', 'Label', 'LeadingVisual', 'Validation']) From c7ba7c394d37ca197bf3c4334dd74e82ed4e3308 Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Mon, 10 Apr 2023 20:09:30 -0600 Subject: [PATCH 4/5] Fix tests --- src/FormControl/FormControl.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FormControl/FormControl.tsx b/src/FormControl/FormControl.tsx index 5efcb21bdcc..99bb5409d74 100644 --- a/src/FormControl/FormControl.tsx +++ b/src/FormControl/FormControl.tsx @@ -211,7 +211,7 @@ const FormControl = React.forwardRef( InputComponent.props, ), )} - {React.Children.toArray(children).filter( + {childrenWithoutSlots.filter( child => React.isValidElement(child) && !expectedInputComponents.some(inputComponent => child.type === inputComponent), From 4ddb79c3d90eb696470eb026df3ffcca93fb816f Mon Sep 17 00:00:00 2001 From: Cole Bemis Date: Mon, 10 Apr 2023 20:17:36 -0600 Subject: [PATCH 5/5] Create .changeset/cool-ghosts-remember.md --- .changeset/cool-ghosts-remember.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/cool-ghosts-remember.md diff --git a/.changeset/cool-ghosts-remember.md b/.changeset/cool-ghosts-remember.md new file mode 100644 index 00000000000..ae941b8b321 --- /dev/null +++ b/.changeset/cool-ghosts-remember.md @@ -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`.