diff --git a/.changeset/sharp-cats-return.md b/.changeset/sharp-cats-return.md
new file mode 100644
index 00000000000..e11de057fb3
--- /dev/null
+++ b/.changeset/sharp-cats-return.md
@@ -0,0 +1,5 @@
+---
+'@primer/react': minor
+---
+
+Introduces FormControl component. The FormControl component combines the functionality of InputField and ChoiceInputField, and will replace FormGroup, InputField, and ChoiceInputField.
diff --git a/docs/content/ChoiceInputField.mdx b/docs/content/ChoiceInputField.mdx
index 272fcef9b3c..fee25034321 100644
--- a/docs/content/ChoiceInputField.mdx
+++ b/docs/content/ChoiceInputField.mdx
@@ -1,7 +1,7 @@
---
componentId: choiceInputField
title: ChoiceInputField
-status: Alpha
+status: Deprecated
description: The ChoiceInputField component is used to render a labelled checkbox or radio inputs with optional hint text.
source: https://github.com/primer/react/blob/main/src/ChoiceInputField.tsx
storybook: '/react/storybook?path=/story/forms-choiceinputfield--checkbox-input-field'
@@ -12,6 +12,10 @@ import {MarkGithubIcon} from '@primer/octicons-react'
import {PropsTable} from '../src/props-table'
import {ComponentChecklist} from '../src/component-checklist'
+## Deprecation
+
+Use [FormControl](/FormControl) instead.
+
## Examples
### Checkbox
diff --git a/docs/content/FormControl.mdx b/docs/content/FormControl.mdx
new file mode 100644
index 00000000000..3ca1e7ef097
--- /dev/null
+++ b/docs/content/FormControl.mdx
@@ -0,0 +1,356 @@
+---
+componentId: form_control
+title: FormControl
+status: Alpha
+description: The FormControl component is used to render a labelled text input and, optionally, associated validation text and hint text.
+source: https://github.com/primer/react/blob/main/src/FormControl/FormControl.tsx
+storybook: '/react/storybook?path=/story/forms-inputfield--text-input-field'
+---
+
+import {FormControl, TextInputWithTokens, Autocomplete, Select, Textarea, Checkbox, Radio, Text} from '@primer/react'
+import {MarkGithubIcon} from '@primer/octicons-react'
+
+## Examples
+
+### Basic
+
+```jsx live
+
+ Name
+
+
+```
+
+### With complex inputs
+
+```javascript live noinline
+const DifferentInputs = () => {
+ const [tokens, setTokens] = React.useState([
+ {text: 'zero', id: 0},
+ {text: 'one', id: 1},
+ {text: 'two', id: 2}
+ ])
+ const onTokenRemove = tokenId => {
+ setTokens(tokens.filter(token => token.id !== tokenId))
+ }
+
+ return (
+
+
+ TextInputWithTokens
+
+
+
+ Autocomplete
+
+
+
+
+
+
+
+
+ Select
+
+
+
+ Textarea
+
+
+
+ )
+}
+
+render(DifferentInputs)
+```
+
+### With checkbox and radio inputs
+
+```jsx live
+
+
+
+
+```
+
+### Required
+
+```jsx live
+
+ Name
+
+
+```
+
+
+ Checkbox and radio form controls cannot be required individually. Instead, you can require a selection from the entire
+ group of checkboxes or radios.
+
+
+### Disabled
+
+```jsx live
+
+
+ Name
+
+
+
+
+ Checkbox option
+
+
+
+```
+
+### With a visually hidden label
+
+```jsx live
+
+
+ Name
+
+
+
+ Checkbox option
+
+
+
+```
+
+
+
+We encourage using `FormControl` alongside all standalone form components like [`TextInput`](/TextInput), as every input must have a corresponding label to be accessible to assistive technology.
+``
+
+`FormControl` also provides an interface for showing a hint text caption and a validation message, and associating those with the input for assistive technology.
+
+
+
+### With a caption
+
+```jsx live
+
+
+ Name
+
+ Hint: your first name
+
+
+ Option one
+
+ Hint: the first and only option
+
+
+```
+
+### With validation
+
+Validation messages are not used for an individual checkbox or radio form control.
+
+```javascript live noinline
+const ValidationExample = () => {
+ 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 (
+
+ GitHub handle
+
+ {validationResult === 'noSpaces' && (
+ GitHub handles cannot contain spaces
+ )}
+ {validationResult === 'validName' && Valid name}
+ With or without "@". For example "monalisa" or "@monalisa"
+
+ )
+}
+
+render(ValidationExample)
+```
+
+### With a leading visual
+
+Only a checkbox or radio form control may have a leading visual.
+
+```jsx live
+<>
+
+ Option one
+
+
+
+
+
+
+
+ Option two
+
+
+
+
+ This one has a caption
+
+>
+```
+
+## Props
+
+### FormControl
+
+The container that handles the layout and passes the relevant IDs and ARIA attributes it's children.
+
+
+
+
+
+
+
+
+### FormControl.Label
+
+A `FormControl.Label` must be passed for the field to be accessible to assistive technology, but it may be visually hidden.
+
+
+
+
+
+### FormControl.Caption
+
+`FormControl.Caption` may be used to render hint text for fields that require additional context.
+
+
+
+
+
+### FormControl.Validation
+
+Use `FormControl.Validation` to render contextual validation information if necessary.
+
+
+
+
+
+
+ Validation messages should not be shown for an individual checkbox or radio form control, so `FormControl.Validation`
+ will not be rendered when a `Checkbox` or `Radio` is not a child of `FormControl`. Validation messages for checkbox
+ and radio selections should only apply to the entire group of inputs.
+
+
+### FormControl.LeadingVisual
+
+Use `FormControl.LeadingVisual` if the selectable option is easier to understand with a visual.
+
+
+
+
+
+
+
+Only a checkbox or radio form control may have a leading visual. If you want to render a leading visual
+**inside** an input, check if that input component supports a leading visual.
+
+
+
+## Component status
+
+
diff --git a/docs/content/FormGroup.md b/docs/content/FormGroup.md
index f359ac11789..7ed49c395af 100644
--- a/docs/content/FormGroup.md
+++ b/docs/content/FormGroup.md
@@ -1,11 +1,15 @@
---
componentId: form_group
title: FormGroup
-status: Alpha
+status: Deprecated
---
Adds styles for multiple form elements used together.
+## Deprecation
+
+Use [FormControl](/FormControl) instead.
+
## Default example
```jsx live
diff --git a/docs/content/InputField.mdx b/docs/content/InputField.mdx
index 2f6b71adbde..d72b7cd30c9 100644
--- a/docs/content/InputField.mdx
+++ b/docs/content/InputField.mdx
@@ -1,7 +1,7 @@
---
componentId: inputField
title: InputField
-status: Alpha
+status: Deprecated
description: The InputField component is used to render a labelled text input and, optionally, associated validation text and hint text.
source: https://github.com/primer/react/blob/main/src/InputField/InputField.tsx
storybook: '/react/storybook?path=/story/forms-inputfield--text-input-field'
@@ -9,6 +9,10 @@ storybook: '/react/storybook?path=/story/forms-inputfield--text-input-field'
import {InputField, TextInputWithTokens, Autocomplete, Select} from '@primer/react'
+## Deprecation
+
+Use [FormControl](/FormControl) instead.
+
## Examples
### Basic
diff --git a/docs/src/@primer/gatsby-theme-doctocat/nav.yml b/docs/src/@primer/gatsby-theme-doctocat/nav.yml
index abf089f88df..9584bf38cf0 100644
--- a/docs/src/@primer/gatsby-theme-doctocat/nav.yml
+++ b/docs/src/@primer/gatsby-theme-doctocat/nav.yml
@@ -53,12 +53,10 @@
url: /Buttons
- title: Checkbox
url: /Checkbox
- # Temporarily hiding form field layout components due to
- # feedback about the names being unclear
+ # Temporarily hiding ChoiceFieldset because the API
+ # is being refactored with breaking changes
# - title: ChoiceFieldset
# url: /ChoiceFieldset
- # - title: ChoiceInputField
- # url: /ChoiceInputField
- title: CircleBadge
url: /CircleBadge
- title: CircleOcticon
@@ -73,16 +71,12 @@
url: /FilterList
- title: Flash
url: /Flash
- - title: FormGroup
- url: /FormGroup
+ - title: FormControl
+ url: /FormControl
- title: Header
url: /Header
- title: Heading
url: /Heading
- # Temporarily hiding form field layout components due to
- # feedback about the names being unclear
- # - title: InputField
- # url: /InputField
- title: Label
url: /Label
- title: LabelGroup
@@ -165,5 +159,7 @@
url: /deprecated/Dialog
- title: Dropdown
url: /deprecated/Dropdown
+ - title: FormGroup
+ url: /FormGroup
- title: SelectMenu
url: /deprecated/SelectMenu
diff --git a/src/FormControl/FormControl.tsx b/src/FormControl/FormControl.tsx
new file mode 100644
index 00000000000..01645f1b97c
--- /dev/null
+++ b/src/FormControl/FormControl.tsx
@@ -0,0 +1,217 @@
+import React from 'react'
+import {Autocomplete, Box, Checkbox, Radio, Select, Textarea, TextInput, TextInputWithTokens, useSSRSafeId} from '..'
+import InputValidation from '../_InputValidation'
+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 type FormControlProps = {
+ children?: React.ReactNode
+ /**
+ * Whether the control allows user input
+ */
+ disabled?: boolean
+ /**
+ * The unique identifier for this control. Used to associate the label, validation text, and caption text
+ */
+ id?: string
+ /**
+ * If true, the user must specify a value for the input before the owning form can be submitted
+ */
+ required?: boolean
+}
+
+export interface FormControlContext extends Pick {
+ captionId: string
+ validationMessageId: string
+}
+
+const FormControl = ({children, disabled, id: idProp, required}: FormControlProps) => {
+ 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
+ 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 (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 (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 (
+
+ {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}}}
+ >
+ {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,
+ 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/Select.tsx b/src/Select.tsx
index ca081479018..96deda5455c 100644
--- a/src/Select.tsx
+++ b/src/Select.tsx
@@ -14,6 +14,7 @@ const StyledSelect = styled.select`
border: 0;
color: currentColor;
outline: none;
+ width: 100%;
/* colors the select input's placeholder text */
&:invalid {
diff --git a/src/__tests__/FormControl.test.tsx b/src/__tests__/FormControl.test.tsx
new file mode 100644
index 00000000000..ec5ff6002f9
--- /dev/null
+++ b/src/__tests__/FormControl.test.tsx
@@ -0,0 +1,434 @@
+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', () => {
+ const mockWarningFn = jest.fn()
+ const mockErrorFn = jest.fn()
+
+ beforeAll(() => {
+ jest.spyOn(global.console, 'warn').mockImplementation(mockWarningFn)
+ jest.spyOn(global.console, 'error').mockImplementation(mockErrorFn)
+ })
+
+ afterAll(() => {
+ jest.clearAllMocks()
+ })
+
+ describe('vertically stacked layout (default)', () => {
+ 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}
+
+
+
+ )
+
+ const input = getByLabelText(LABEL_TEXT)
+ const label = getByText(LABEL_TEXT)
+
+ expect(input).toBeDefined()
+ expect(label).toBeDefined()
+ })
+ })
+
+ describe('ARIA attributes', () => {
+ it('associates the label with the input', () => {
+ const {getByLabelText} = render(
+
+
+ {LABEL_TEXT}
+
+
+
+ )
+
+ const inputNode = getByLabelText(LABEL_TEXT)
+ expect(inputNode).toBeDefined()
+ })
+ it('associates caption text with the input', () => {
+ const fieldId = 'captionedInput'
+ const {getByLabelText, getByText} = render(
+
+
+ {LABEL_TEXT}
+
+ {CAPTION_TEXT}
+
+
+ )
+
+ const inputNode = getByLabelText(LABEL_TEXT)
+ const captionNode = getByText(CAPTION_TEXT)
+
+ expect(captionNode.getAttribute('id')).toBe(`${fieldId}-caption`)
+ expect(inputNode.getAttribute('aria-describedby')).toBe(`${fieldId}-caption`)
+ })
+ it('associates validation text with the input', () => {
+ const fieldId = 'validatedInput'
+ const {getByLabelText, getByText} = render(
+
+
+ {LABEL_TEXT}
+
+ {ERROR_TEXT}
+
+
+ )
+
+ const inputNode = getByLabelText(LABEL_TEXT)
+ const validationNode = getByText(ERROR_TEXT)
+
+ expect(validationNode.getAttribute('id')).toBe(`${fieldId}-validationMsg`)
+ expect(inputNode.getAttribute('aria-describedby')).toBe(`${fieldId}-validationMsg`)
+ })
+ })
+
+ describe('warnings', () => {
+ it('should warn users if they do not pass an input', async () => {
+ render(
+
+
+ {LABEL_TEXT}
+ {CAPTION_TEXT}
+
+
+ )
+
+ expect(mockWarningFn).toHaveBeenCalled()
+ })
+ it('should warn users if they try to render a choice (checkbox or radio) input', async () => {
+ render(
+
+
+ {LABEL_TEXT}
+
+ {CAPTION_TEXT}
+
+
+ )
+
+ expect(mockWarningFn).toHaveBeenCalled()
+ })
+ it('should log an error if a user does not pass a label', async () => {
+ render(
+
+
+
+ {CAPTION_TEXT}
+
+
+ )
+
+ expect(mockErrorFn).toHaveBeenCalled()
+ })
+ it('should warn users if they try to render a leading visual when using variant="stack"', async () => {
+ render(
+
+
+
+
+
+ Name
+
+
+
+ )
+
+ expect(mockWarningFn).toHaveBeenCalled()
+ })
+ it('should warn users if they pass an id directly to the input', async () => {
+ render(
+
+
+ {LABEL_TEXT}
+
+ {CAPTION_TEXT}
+
+
+ )
+
+ expect(mockWarningFn).toHaveBeenCalled()
+ })
+ it('should warn users if they pass a `disabled` prop directly to the input', async () => {
+ render(
+
+
+ {LABEL_TEXT}
+
+ {CAPTION_TEXT}
+
+
+ )
+
+ expect(mockWarningFn).toHaveBeenCalled()
+ })
+ it('should warn users if they pass a `required` prop directly to the input', async () => {
+ render(
+
+
+ {LABEL_TEXT}
+
+ {CAPTION_TEXT}
+
+
+ )
+
+ expect(mockWarningFn).toHaveBeenCalled()
+ })
+ })
+ it('should have no axe violations', async () => {
+ const {container} = render(
+
+
+ {LABEL_TEXT}
+
+ {CAPTION_TEXT}
+
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ cleanup()
+ })
+ })
+
+ describe('checkbox and radio layout', () => {
+ describe('rendering', () => {
+ it('renders with a LeadingVisual', () => {
+ const {getByLabelText} = render(
+
+
+ {LABEL_TEXT}
+
+
+
+
+
+
+ )
+
+ const leadingVisual = getByLabelText('leadingVisualIcon')
+
+ expect(leadingVisual).toBeDefined()
+ })
+ })
+
+ describe('warnings', () => {
+ it('should warn users if they try to render a validation message when using a checkbox or radio', async () => {
+ const consoleSpy = jest.spyOn(global.console, 'warn')
+ render(
+
+
+ {LABEL_TEXT}
+
+ Some error
+ {CAPTION_TEXT}
+
+
+ )
+
+ expect(consoleSpy).toHaveBeenCalled()
+ })
+ it('should warn users if they pass `required` to a checkbox or radio', async () => {
+ const consoleSpy = jest.spyOn(global.console, 'warn')
+ render(
+
+
+ {LABEL_TEXT}
+
+ {CAPTION_TEXT}
+
+
+ )
+
+ expect(consoleSpy).toHaveBeenCalled()
+ })
+ })
+ it('should have no axe violations', async () => {
+ const {container} = render(
+
+
+ {LABEL_TEXT}
+
+ {CAPTION_TEXT}
+
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+ })
+})
diff --git a/src/index.ts b/src/index.ts
index ade861fdd6b..6a5c26f0c47 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -96,6 +96,7 @@ export {default as FilterList} from './FilterList'
export type {FilterListProps, FilterListItemProps} from './FilterList'
export {default as Flash} from './Flash'
export type {FlashProps} from './Flash'
+export {default as FormControl} from './FormControl'
export {default as FormGroup} from './FormGroup'
export type {FormGroupProps, FormGroupLabelProps} from './FormGroup'
export {default as Header} from './Header'
diff --git a/src/stories/ChoiceInputField.stories.tsx b/src/stories/ChoiceInputField.stories.tsx
deleted file mode 100644
index 9d777d6e3d8..00000000000
--- a/src/stories/ChoiceInputField.stories.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import React from 'react'
-import {Meta} from '@storybook/react'
-import {BaseStyles, Checkbox, Radio, ThemeProvider} from '..'
-import {ComponentProps} from '../utils/types'
-import ChoiceInputField from '../ChoiceInputField'
-import {MarkGithubIcon} from '@primer/octicons-react'
-
-type Args = ComponentProps
-
-export default {
- title: 'Forms/ChoiceInputField',
- component: ChoiceInputField,
- argTypes: {
- disabled: {
- defaultValue: false
- }
- },
- parameters: {controls: {exclude: ['id', 'validationStatus']}},
- decorators: [
- Story => {
- return (
-
-
-
-
-
- )
- }
- ]
-} as Meta
-
-export const CheckboxInputField = (args: Args) => (
-
-
- Selectable choice
-
-)
-
-export const RadioInputField = (args: Args) => (
-
-
- Selectable choice
-
-)
-
-export const WithAVisuallyHiddenLabel = (args: Args) => (
-
- Selectable choice
-
-
-)
-
-export const WithLeadingVisual = (args: Args) => (
-
- Selectable choice
-
-
-
-
-
-)
-WithLeadingVisual.storyName = 'With LeadingVisual'
-
-export const WithCaption = (args: Args) => (
-
- Selectable choice
-
- This is an arbitrary choice
-
-)
-
-export const WithCaptionAndLeadingVisual = (args: Args) => (
-
- Selectable choice
-
-
-
-
- This is an arbitrary choice
-
-)
-WithCaptionAndLeadingVisual.storyName = 'With Caption and LeadingVisual'
diff --git a/src/stories/FormControl.stories.tsx b/src/stories/FormControl.stories.tsx
new file mode 100644
index 00000000000..55f7a287142
--- /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'
diff --git a/src/stories/InputField.stories.tsx b/src/stories/InputField.stories.tsx
deleted file mode 100644
index 42e153d911b..00000000000
--- a/src/stories/InputField.stories.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-import React from 'react'
-import {Meta} from '@storybook/react'
-import {BaseStyles, Select, TextInput, TextInputWithTokens, ThemeProvider} from '..'
-import InputField from '../InputField'
-import {ComponentProps} from '../utils/types'
-import Autocomplete from '../Autocomplete'
-
-type Args = ComponentProps
-
-export default {
- title: 'Forms/InputField',
- component: InputField,
- argTypes: {
- required: {
- defaultValue: false
- },
- disabled: {
- defaultValue: false
- },
- validationResult: {
- options: ['noSpaces', 'validResult'],
- control: {type: 'radio'},
- defaultValue: 'noSpaces'
- },
- validationMap: {
- defaultValue: {
- noSpaces: 'error',
- validName: 'success'
- }
- }
- },
- parameters: {controls: {exclude: ['id', 'validationMap', 'validationResult']}},
- decorators: [
- Story => {
- return (
-
-
-
-
-
- )
- }
- ]
-} as Meta
-
-export const TextInputField = (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
- This name is valid
-
-)
-WithValidation.parameters = {controls: {exclude: ['id']}}
-
-export const WithValidationAndCaption = (args: Args) => (
-
- Name
-
- Your first name cannot contain spaces
- This name is valid
- 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'