From 216178c00045e2fef1c1937ef67133be338d44cd Mon Sep 17 00:00:00 2001 From: Mike Perrotti Date: Wed, 2 Feb 2022 18:07:08 -0500 Subject: [PATCH 01/20] adds FormControl component and stories --- src/FormControl/FormControl.tsx | 238 ++++++++++++++++++ src/FormControl/_FormControlCaption.tsx | 16 ++ src/FormControl/_FormControlLabel.tsx | 23 ++ src/FormControl/_FormControlLeadingVisual.tsx | 6 + src/FormControl/_FormControlValidation.tsx | 13 + src/FormControl/index.ts | 1 + src/FormControl/slots.ts | 3 + src/stories/FormControl.stories.tsx | 163 ++++++++++++ 8 files changed, 463 insertions(+) create mode 100644 src/FormControl/FormControl.tsx create mode 100644 src/FormControl/_FormControlCaption.tsx create mode 100644 src/FormControl/_FormControlLabel.tsx create mode 100644 src/FormControl/_FormControlLeadingVisual.tsx create mode 100644 src/FormControl/_FormControlValidation.tsx create mode 100644 src/FormControl/index.ts create mode 100644 src/FormControl/slots.ts create mode 100644 src/stories/FormControl.stories.tsx diff --git a/src/FormControl/FormControl.tsx b/src/FormControl/FormControl.tsx new file mode 100644 index 00000000000..2bd647f7f06 --- /dev/null +++ b/src/FormControl/FormControl.tsx @@ -0,0 +1,238 @@ +import React from 'react' +import {Autocomplete, Box, Checkbox, Radio, Select, Textarea, TextInput, TextInputWithTokens, useSSRSafeId} from '..' +import InputValidation from '../_InputValidation' +import {ComponentProps} from '../utils/types' +import FormControlCaption from './_FormControlCaption' +import FormControlLabel from './_FormControlLabel' +import FormControlValidation from './_FormControlValidation' +import {Slots} from './slots' +import ValidationAnimationContainer from '../_ValidationAnimationContainer' +import {get} from '../constants' +import FormControlLeadingVisual from './_FormControlLeadingVisual' + +export interface Props { + children?: React.ReactNode + /** + * Whether the field is ready for user input + */ + disabled?: boolean + /** + * The unique identifier for this field. Used to associate the label, validation text, and caption text + */ + id?: string + /** + * Whether this field must have a value for the user to complete their task + */ + required?: boolean + /** + * Changes the layout of the form control + */ + variant?: 'stack' | 'choice' // TODO: come up with a better name for 'stack' +} + +type FormControlValidationProps = ComponentProps +export interface FormControlContext extends Pick { + captionId: string + validationMessageId: string +} + +const FormControl = ({children, disabled, id: idProp, required, variant = 'stack'}: Props) => { + const expectedInputComponents = [Autocomplete, Checkbox, Radio, Select, TextInput, TextInputWithTokens, Textarea] + const id = useSSRSafeId(idProp) + const validationChild = React.Children.toArray(children).find(child => + React.isValidElement(child) && child.type === FormControlValidation ? child : null + ) + const captionChildren: React.ReactElement[] | undefined | null = React.Children.map(children, child => + React.isValidElement(child) && child.type === FormControlCaption ? child : null + )?.filter(Boolean) + const labelChild: React.ReactNode | undefined | null = React.Children.toArray(children).find( + child => React.isValidElement(child) && child.type === FormControlLabel + ) + const validationMessageId = validationChild ? `${id}-validationMsg` : '' + const validationStatus = React.isValidElement(validationChild) ? validationChild.props.appearance : undefined + const captionId = captionChildren?.length ? `${id}-caption` : undefined + const InputComponent = React.Children.toArray(children).find(child => + expectedInputComponents.some(inputComponent => React.isValidElement(child) && child.type === inputComponent) + ) + const inputProps = React.isValidElement(InputComponent) ? InputComponent.props : undefined + + 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 (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.` + ) + } + + if (variant === 'choice') { + // TODO: reconsider if we even need this check. The UI will just look like something is wrong + if (React.isValidElement(InputComponent) && (InputComponent.type !== Checkbox || InputComponent.type !== Radio)) { + // eslint-disable-next-line no-console + console.warn('Only the Checkbox or Radio components are inteded to be used with the "choice" variant') + } + + 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 { + // TODO: reconsider if we even need this check. The UI will just look like something is wrong + if (React.isValidElement(InputComponent) && (InputComponent.type === Checkbox || InputComponent.type === Radio)) { + // eslint-disable-next-line no-console + console.warn('The Checkbox or Radio components are not intended to be used with the "stack" variant') + } + + 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 ( + + {slots => { + const isLabelHidden = React.isValidElement(slots.Label) && slots.Label.props.visuallyHidden + + return variant === 'choice' ? ( + + 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}}} + > + {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} + + ) + }} + + ) +} + +FormControl.defaultProps = { + variant: 'stack' +} + +export type FormControlComponentProps = ComponentProps +export default Object.assign(FormControl, { + Caption: FormControlCaption, + Label: FormControlLabel, + LeadingVisual: FormControlLeadingVisual, + Validation: FormControlValidation +}) diff --git a/src/FormControl/_FormControlCaption.tsx b/src/FormControl/_FormControlCaption.tsx new file mode 100644 index 00000000000..07ec2b4f4e2 --- /dev/null +++ b/src/FormControl/_FormControlCaption.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import InputCaption from '../_InputCaption' +import {FormControlContext} from './FormControl' +import {Slot} from './slots' + +const FormControlCaption: React.FC = ({children}) => ( + + {({captionId, disabled}: FormControlContext) => ( + + {children} + + )} + +) + +export default FormControlCaption diff --git a/src/FormControl/_FormControlLabel.tsx b/src/FormControl/_FormControlLabel.tsx new file mode 100644 index 00000000000..8808de852aa --- /dev/null +++ b/src/FormControl/_FormControlLabel.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import InputLabel from '../_InputLabel' +import {FormControlContext} from './FormControl' +import {Slot} from './slots' + +export interface Props { + /** + * Whether the label should be visually hidden + */ + visuallyHidden?: boolean +} + +const FormControlLabel: React.FC = ({children, visuallyHidden}) => ( + + {({disabled, id, required}: FormControlContext) => ( + + {children} + + )} + +) + +export default FormControlLabel diff --git a/src/FormControl/_FormControlLeadingVisual.tsx b/src/FormControl/_FormControlLeadingVisual.tsx new file mode 100644 index 00000000000..ecd9d8caa50 --- /dev/null +++ b/src/FormControl/_FormControlLeadingVisual.tsx @@ -0,0 +1,6 @@ +import React from 'react' +import {Slot} from './slots' + +const FormControlLeadingVisual: React.FC = ({children}) => {children} + +export default FormControlLeadingVisual diff --git a/src/FormControl/_FormControlValidation.tsx b/src/FormControl/_FormControlValidation.tsx new file mode 100644 index 00000000000..28f53a332d3 --- /dev/null +++ b/src/FormControl/_FormControlValidation.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import {FormValidationStatus} from '../utils/types/FormValidationStatus' +import {Slot} from './slots' + +export interface FormControlValidationProps { + appearance: FormValidationStatus +} + +const FormControlValidation: React.FC = ({children}) => ( + {children} +) + +export default FormControlValidation diff --git a/src/FormControl/index.ts b/src/FormControl/index.ts new file mode 100644 index 00000000000..5850302e571 --- /dev/null +++ b/src/FormControl/index.ts @@ -0,0 +1 @@ +export {default} from './FormControl' diff --git a/src/FormControl/slots.ts b/src/FormControl/slots.ts new file mode 100644 index 00000000000..8eb67996d6c --- /dev/null +++ b/src/FormControl/slots.ts @@ -0,0 +1,3 @@ +import createSlots from '../utils/create-slots' + +export const {Slots, Slot} = createSlots(['Caption', 'Label', 'LeadingVisual', 'Validation']) diff --git a/src/stories/FormControl.stories.tsx b/src/stories/FormControl.stories.tsx new file mode 100644 index 00000000000..06248e5af22 --- /dev/null +++ b/src/stories/FormControl.stories.tsx @@ -0,0 +1,163 @@ +import React from 'react' +import {Meta} from '@storybook/react' +import {BaseStyles, Checkbox, Radio, Select, TextInput, TextInputWithTokens, ThemeProvider} from '..' +import FormControl from '../FormControl' +import {ComponentProps} from '../utils/types' +import Autocomplete from '../Autocomplete' +import {MarkGithubIcon} from '@primer/octicons-react' + +type Args = ComponentProps + +export default { + title: 'Forms/FormControl', + component: FormControl, + argTypes: { + required: { + defaultValue: false + }, + disabled: { + defaultValue: false + } + }, + parameters: {controls: {exclude: ['variant', 'id']}}, + decorators: [ + Story => { + return ( + + + + + + ) + } + ] +} as Meta + +export const TextInputFormControl = (args: Args) => ( + + Name + + +) + +export const WithAVisuallyHiddenLabel = (args: Args) => ( + + Name + + +) + +export const WithCaption = (args: Args) => ( + + Name + + Hint: your first name + +) +export const WithValidation = (args: Args) => ( + + Name + + Your first name cannot contain spaces + +) +WithValidation.parameters = {controls: {exclude: ['id']}} + +export const WithValidationAndCaption = (args: Args) => ( + + Name + + Your first name cannot contain spaces + Hint: your first name + +) +WithValidationAndCaption.parameters = {controls: {exclude: ['id']}} + +export const UsingAutocompleteInput = (args: Args) => { + return ( + + Tags + + + + + + + + ) +} + +export const UsingTextInputWithTokens = (args: Args) => ( + + Tags + + +) + +export const UsingSelectInput = (args: Args) => ( + + Preferred Primer component interface + + +) + +UsingTextInputWithTokens.storyName = 'Using TextInputWithTokens Input' + +export const UsingCheckboxInput = (args: Args) => ( + + + Selectable choice + +) + +export const UsingRadioInput = (args: Args) => ( + + + Selectable choice + +) + +export const WithLeadingVisual = (args: Args) => ( + + Selectable choice + + + + + +) +WithLeadingVisual.storyName = 'With LeadingVisual' + +export const WithCaptionAndLeadingVisual = (args: Args) => ( + + Selectable choice + + + + + This is an arbitrary choice + +) +WithCaptionAndLeadingVisual.storyName = 'With Caption and LeadingVisual' From 2df18366d75ff162af7fc7f29f6f180ed01e01d5 Mon Sep 17 00:00:00 2001 From: Mike Perrotti Date: Thu, 3 Feb 2022 12:18:41 -0500 Subject: [PATCH 02/20] adds tests --- src/FormControl/FormControl.tsx | 8 +- src/__tests__/FormControl.test.tsx | 443 +++++++++++++++++++++++++++++ src/index.ts | 1 + 3 files changed, 445 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/FormControl.test.tsx diff --git a/src/FormControl/FormControl.tsx b/src/FormControl/FormControl.tsx index 2bd647f7f06..32631d49e36 100644 --- a/src/FormControl/FormControl.tsx +++ b/src/FormControl/FormControl.tsx @@ -98,12 +98,6 @@ const FormControl = ({children, disabled, id: idProp, required, variant = 'stack } if (variant === 'choice') { - // TODO: reconsider if we even need this check. The UI will just look like something is wrong - if (React.isValidElement(InputComponent) && (InputComponent.type !== Checkbox || InputComponent.type !== Radio)) { - // eslint-disable-next-line no-console - console.warn('Only the Checkbox or Radio components are inteded to be used with the "choice" variant') - } - if (validationChild) { // eslint-disable-next-line no-console console.warn( @@ -119,7 +113,7 @@ const FormControl = ({children, disabled, id: idProp, required, variant = 'stack // TODO: reconsider if we even need this check. The UI will just look like something is wrong if (React.isValidElement(InputComponent) && (InputComponent.type === Checkbox || InputComponent.type === Radio)) { // eslint-disable-next-line no-console - console.warn('The Checkbox or Radio components are not intended to be used with the "stack" variant') + console.warn('The Checkbox or Radio components are only intended to be used with the "choice" variant') } if ( diff --git a/src/__tests__/FormControl.test.tsx b/src/__tests__/FormControl.test.tsx new file mode 100644 index 00000000000..60bdbdda516 --- /dev/null +++ b/src/__tests__/FormControl.test.tsx @@ -0,0 +1,443 @@ +import React from 'react' +import {render, cleanup} from '@testing-library/react' +import {axe, toHaveNoViolations} from 'jest-axe' +import 'babel-polyfill' +import {Autocomplete, Checkbox, FormControl, Select, SSRProvider, Textarea, TextInput, TextInputWithTokens} from '..' +import {MarkGithubIcon} from '@primer/octicons-react' +expect.extend(toHaveNoViolations) + +const LABEL_TEXT = 'Form control' +const CAPTION_TEXT = 'Hint text' +const ERROR_TEXT = 'This field is invalid' + +describe('FormControl', () => { + describe('default variant', () => { + describe('rendering', () => { + it('renders with a hidden label', () => { + const {getByLabelText, getByText} = render( + + + {LABEL_TEXT} + + + + ) + + const input = getByLabelText(LABEL_TEXT) + const label = getByText(LABEL_TEXT) + + expect(input).toBeDefined() + expect(label).toBeDefined() + }) + it('renders with a custom ID', () => { + const {getByLabelText} = render( + + + {LABEL_TEXT} + + + + ) + + const input = getByLabelText(LABEL_TEXT) + + expect(input.getAttribute('id')).toBe('customId') + }) + it('renders as disabled', () => { + const {getByLabelText} = render( + + + {LABEL_TEXT} + + + + ) + + const input = getByLabelText(LABEL_TEXT) + + expect(input.getAttribute('disabled')).not.toBeNull() + }) + it('renders as required', () => { + const {getByRole} = render( + + + {LABEL_TEXT} + + + + ) + + const input = getByRole('textbox') + + expect(input.getAttribute('required')).not.toBeNull() + }) + it('renders with a caption', () => { + const {getByText} = render( + + + {LABEL_TEXT} + + {CAPTION_TEXT} + + + ) + + const caption = getByText(CAPTION_TEXT) + + expect(caption).toBeDefined() + }) + it('renders with a successful validation message', () => { + const {getByText} = render( + + + {LABEL_TEXT} + + {ERROR_TEXT} + + + ) + + const validationMessage = getByText(ERROR_TEXT) + + expect(validationMessage).toBeDefined() + }) + it('renders with an error validation message', () => { + const {getByText} = render( + + + {LABEL_TEXT} + + {ERROR_TEXT} + + + ) + + const validationMessage = getByText(ERROR_TEXT) + + expect(validationMessage).toBeDefined() + }) + it('renders with the input as a TextInputWithTokens', () => { + const onRemoveMock = jest.fn() + const {getByLabelText} = render( + + + {LABEL_TEXT} + + + + ) + + const input = getByLabelText(LABEL_TEXT) + + expect(input).toBeDefined() + }) + it('renders with the input as an Autocomplete', () => { + const {getByLabelText} = render( + + + {LABEL_TEXT} + + + + + + ) + + const input = getByLabelText(LABEL_TEXT) + + expect(input).toBeDefined() + }) + it('renders with the input as a Select', () => { + const {getByLabelText, getByText} = render( + + + {LABEL_TEXT} + + + + ) + + const input = getByLabelText(LABEL_TEXT) + const label = getByText(LABEL_TEXT) + + expect(input).toBeDefined() + expect(label).toBeDefined() + }) + it('renders with the input as a Textarea', () => { + const {getByLabelText, getByText} = render( + + + {LABEL_TEXT} +