Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 677
Let FormControl accept any input#1968
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
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
d2bf844fa6883f58ff21ae11010f31096d78d311d131d09d6ad742b05312212aa500e9File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@primer/react': patch | ||
| --- | ||
| Instead of rendering unexpected FormControl children before the rest of the content, we render them in the same spot we'd normally render a Primer input component |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -91,6 +91,89 @@ const DifferentInputs = () => { | ||
| render(DifferentInputs) | ||
| ``` | ||
| ### With a custom input | ||
| <Note variant="warning"> | ||
| When rendering an input other than a form component from Primer, you must manually pass the attributes that make the form control accessible: | ||
| - The input should have an ID | ||
| - `FormControl.Label` should be associated with the text input by using `htmlFor` | ||
| - If there is a caption, the input should be associated with the caption by passing the message's ID to `aria-describedby` | ||
| - If there is a validation message, the input should be associated with the message by passing the message's ID to `aria-describedby` | ||
| - If there is both a caption and a validation message, the input should be associated with the message by passing the both the validation message's ID and the caption's ID to `aria-describedby`. Example: `aria-describedby="caption-id validation-id"` | ||
| - If the input's value is invalid, `aria-invalid={true}` should be passed to the input. | ||
| - If the input is disabled, `disabled` should be passed. | ||
| - If the input is required, `required` should be passed. | ||
| When rendering a custom checkbox or radio component, you must also pass `layout="horizontal"` to the `FormControl` component. | ||
| </Note> | ||
| ```javascript live noinline | ||
| const CustomTextInput = props => <input type="text" {...props} /> | ||
| const CustomCheckboxInput = props => <input type="checkbox" {...props} /> | ||
| const FormControlWithCustomInput = () => { | ||
| const [value, setValue] = React.useState('mona lisa') | ||
| const [validationResult, setValidationResult] = React.useState() | ||
| const doesValueContainSpaces = inputValue => /\s/g.test(inputValue) | ||
| const handleInputChange = e => { | ||
| setValue(e.currentTarget.value) | ||
| } | ||
| React.useEffect(() => { | ||
| if (doesValueContainSpaces(value)) { | ||
| setValidationResult('noSpaces') | ||
| } else if (value) { | ||
| setValidationResult('validName') | ||
| } | ||
| }, [value]) | ||
| return ( | ||
| <Box display="grid" gridGap={3}> | ||
| <FormControl> | ||
| <FormControl.Label htmlFor="custom-input">GitHub handle</FormControl.Label> | ||
| <CustomTextInput | ||
mperrotti marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| id="custom-input" | ||
| aria-describedby="custom-input-caption custom-input-validation" | ||
| aria-invalid={validationResult === 'noSpaces'} | ||
mperrotti marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| onChange={handleInputChange} | ||
| /> | ||
| {validationResult === 'noSpaces' && ( | ||
| <FormControl.Validation id="custom-input-validation" variant="error"> | ||
| GitHub handles cannot contain spaces | ||
| </FormControl.Validation> | ||
| )} | ||
| {validationResult === 'validName' && ( | ||
| <FormControl.Validation id="custom-input-validation" variant="success"> | ||
| Valid name | ||
| </FormControl.Validation> | ||
| )} | ||
| <FormControl.Caption id="custom-input-caption"> | ||
| With or without "@". For example "monalisa" or "@monalisa" | ||
| </FormControl.Caption> | ||
| </FormControl> | ||
| <CheckboxGroup> | ||
| <CheckboxGroup.Label>Checkboxes</CheckboxGroup.Label> | ||
| <FormControl layout="horizontal"> | ||
| <CustomCheckboxInput id="custom-checkbox-one" value="checkOne" /> | ||
| <FormControl.Label htmlFor="custom-checkbox-one">Checkbox one</FormControl.Label> | ||
| <FormControl.Caption id="custom-checkbox-one-caption">Hint text for checkbox one</FormControl.Caption> | ||
| </FormControl> | ||
| <FormControl layout="horizontal"> | ||
| <CustomCheckboxInput id="custom-checkbox-two" value="checkTwo" /> | ||
| <FormControl.Label htmlFor="custom-checkbox-two">Checkbox two</FormControl.Label> | ||
| <FormControl.Caption id="custom-checkbox-two-caption">Hint text for checkbox two</FormControl.Caption> | ||
| </FormControl> | ||
| </CheckboxGroup> | ||
| </Box> | ||
| ) | ||
| } | ||
| render(FormControlWithCustomInput) | ||
| ``` | ||
| ### With checkbox and radio inputs | ||
| ```jsx live | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -24,6 +24,11 @@ export type FormControlProps = { | ||
| * If true, the user must specify a value for the input before the owning form can be submitted | ||
| */ | ||
| required?: boolean | ||
| /** | ||
| * The direction the content flows. | ||
| * Vertical layout is used by default, and horizontal layout is used for checkbox and radio inputs. | ||
| */ | ||
| layout?: 'horizontal' | 'vertical' | ||
| } & SxProp | ||
| export interface FormControlContext extends Pick<FormControlProps, 'disabled' | 'id' | 'required'> { | ||
| @@ -32,7 +37,7 @@ export interface FormControlContext extends Pick<FormControlProps, 'disabled' | | ||
| } | ||
| const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>( | ||
| ({children, disabled: disabledProp, id: idProp, required, sx}, ref) => { | ||
| ({children, disabled: disabledProp, layout, id: idProp, required, sx}, ref) => { | ||
| const expectedInputComponents = [Autocomplete, Checkbox, Radio, Select, TextInput, TextInputWithTokens, Textarea] | ||
| const choiceGroupContext = useContext(CheckboxOrRadioGroupContext) | ||
| const disabled = choiceGroupContext?.disabled || disabledProp | ||
| @@ -56,20 +61,7 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>( | ||
| 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.' | ||
| ) | ||
| } else { | ||
| if (InputComponent) { | ||
| if (inputProps?.id) { | ||
| // eslint-disable-next-line no-console | ||
| console.warn( | ||
| @@ -135,7 +127,7 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>( | ||
| {slots => { | ||
| const isLabelHidden = React.isValidElement(slots.Label) && slots.Label.props.visuallyHidden | ||
| return isChoiceInput ? ( | ||
| 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) && | ||
| @@ -183,13 +175,8 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>( | ||
| display="flex" | ||
| flexDirection="column" | ||
| width="100%" | ||
| sx={{...(isLabelHidden ? {'> *:not(label) + *': {marginTop: 2}} : {'> * + *': {marginTop: 2}}), ...sx}} | ||
| sx={{...(isLabelHidden ? {'> *:not(label) + *': {marginTop: 1}} : {'> * + *': {marginTop: 1}}), ...sx}} | ||
ContributorAuthor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This has nothing to do with accepting different input components. | ||
| > | ||
| {React.Children.toArray(children).filter( | ||
| child => | ||
| React.isValidElement(child) && | ||
| !expectedInputComponents.some(inputComponent => child.type === inputComponent) | ||
| )} | ||
| {slots.Label} | ||
| {React.isValidElement(InputComponent) && | ||
| React.cloneElement(InputComponent, { | ||
| @@ -199,6 +186,11 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>( | ||
| validationStatus, | ||
| ['aria-describedby']: [validationMessageId, captionId].filter(Boolean).join(' ') | ||
| })} | ||
| {React.Children.toArray(children).filter( | ||
| child => | ||
| React.isValidElement(child) && | ||
| !expectedInputComponents.some(inputComponent => child.type === inputComponent) | ||
| )} | ||
| {validationChild && <ValidationAnimationContainer show>{slots.Validation}</ValidationAnimationContainer>} | ||
| {slots.Caption} | ||
| </Box> | ||
| @@ -209,6 +201,10 @@ const FormControl = React.forwardRef<HTMLDivElement, FormControlProps>( | ||
| } | ||
| ) | ||
| FormControl.defaultProps = { | ||
| layout: 'vertical' | ||
| } | ||
| export default Object.assign(FormControl, { | ||
| Caption: FormControlCaption, | ||
| Label: FormControlLabel, | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -4,10 +4,10 @@ import InputCaption from '../_InputCaption' | ||
| import {FormControlContext} from './FormControl' | ||
| import {Slot} from './slots' | ||
| const FormControlCaption: React.FC<SxProp> = ({children, sx}) => ( | ||
| const FormControlCaption: React.FC<{id?: string} & SxProp> = ({children, sx, id}) => ( | ||
| <Slot name="Caption"> | ||
| {({captionId, disabled}: FormControlContext) => ( | ||
| <InputCaption id={captionId} disabled={disabled} sx={sx}> | ||
| <InputCaption id={id || captionId} disabled={disabled} sx={sx}> | ||
mperrotti marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| {children} | ||
| </InputCaption> | ||
| )} | ||
Uh oh!
There was an error while loading. Please reload this page.