diff --git a/.changeset/bright-flowers-itch.md b/.changeset/bright-flowers-itch.md new file mode 100644 index 00000000000..72ac5afc6c1 --- /dev/null +++ b/.changeset/bright-flowers-itch.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +`FormControl` now accepts a `ref` prop diff --git a/docs/content/FormControl.mdx b/docs/content/FormControl.mdx index 21a5716f048..9bd343de66c 100644 --- a/docs/content/FormControl.mdx +++ b/docs/content/FormControl.mdx @@ -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" /> + diff --git a/src/FormControl/FormControl.tsx b/src/FormControl/FormControl.tsx index c1ac5ea74f8..7ed7dac30f6 100644 --- a/src/FormControl/FormControl.tsx +++ b/src/FormControl/FormControl.tsx @@ -31,180 +31,183 @@ export interface FormControlContext extends Pick { - 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( + ({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, ` - ) - } - 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, ` - ) - } - 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, ` - ) - } - } - - 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, ` + ) + } + 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, ` + ) + } + 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, ` + ) + } } - 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 => { - 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 ? ( - - input': {marginLeft: 0, marginRight: 0}}}> + return ( + + {slots => { + const isLabelHidden = React.isValidElement(slots.Label) && slots.Label.props.visuallyHidden + + return isChoiceInput ? ( + + 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) + )} + + {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.visuallyHidden) || slots.Caption ? ( + + {slots.Label} + {slots.Caption} + + ) : ( + <> + {slots.Label} + {slots.Caption} + + )} + + ) : ( + *: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 && {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.visuallyHidden) || slots.Caption ? ( - - {slots.Label} - {slots.Caption} - - ) : ( - <> - {slots.Label} - {slots.Caption} - - )} - - ) : ( - *: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 && {slots.Validation}} - {slots.Caption} - - ) - }} - - ) -} + ) + }} + + ) + } +) export default Object.assign(FormControl, { Caption: FormControlCaption,