-
Notifications
You must be signed in to change notification settings - Fork 616
Add Checkbox form component #1606
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
Changes from all commits
519563c
2b1777a
82271fc
b7ac7b1
16726de
3be89c8
596a7d7
47b3d1f
8ad3501
51abf80
f0d70fa
0095ea8
c8292e7
25e231e
0544d41
4461204
9eed075
98c9c9f
4e05656
99645fb
1646d58
876cb66
a09c39e
09ae898
b35d3b3
ff964d6
3f1f8b8
File filter
Filter by extension
Conversations
Jump to
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/components': minor | ||
--- | ||
|
||
Adds a new Checkbox form component |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
--- | ||
title: Checkbox | ||
description: Use checkboxes to toggle between checked and unchecked states in a list or as a standalone form field | ||
status: Alpha | ||
source: https://github.com/primer/react/blob/main/src/Checklist.tsx | ||
storybook: '/react/storybook?path=/story/forms-checkbox--default' | ||
--- | ||
|
||
import {ComponentChecklist} from '../src/component-checklist' | ||
|
||
## Default example | ||
|
||
The `Checkbox` component can be used in controlled and uncontrolled modes. | ||
|
||
```jsx live | ||
<> | ||
<Box as="form" sx={{p: 3, pt: 0, display: 'flex', alignItems: 'center'}}> | ||
<Checkbox id="default-checkbox" /> | ||
<Text as="label" htmlFor="default-checkbox" sx={{fontSize: 2, fontWeight: 'bold', marginLeft: 1}}> | ||
Default checkbox | ||
</Text> | ||
</Box> | ||
<Box as="form" sx={{p: 3, display: 'flex', alignItems: 'center'}}> | ||
<Checkbox id="always-checked-checkbox" checked={true} /> | ||
<Text as="label" htmlFor="always-checked-checkbox" sx={{fontSize: 2, fontWeight: 'bold', marginLeft: 1}}> | ||
Always checked | ||
</Text> | ||
</Box> | ||
<Box as="form" sx={{p: 3, display: 'flex', alignItems: 'center'}}> | ||
<Checkbox id="always-unchecked-checkbox" checked={false} /> | ||
<Text as="label" htmlFor="always-unchecked-checkbox" sx={{fontSize: 2, fontWeight: 'bold', marginLeft: 1}}> | ||
Always unchecked | ||
</Text> | ||
</Box> | ||
|
||
<Box as="form" sx={{p: 3, display: 'flex', alignItems: 'center'}}> | ||
<Checkbox id="inactive-checkbox" checked={true} disabled /> | ||
<Text as="label" htmlFor="inactive-checkbox" sx={{fontSize: 2, fontWeight: 'bold', marginLeft: 1}}> | ||
Inactive | ||
</Text> | ||
</Box> | ||
</> | ||
``` | ||
|
||
<Note variant="warning"> | ||
Checkbox components should always be accompanied by a corresponding label to improve support for assistive technologies. | ||
</Note> | ||
|
||
## Indeterminate example | ||
|
||
An `indeterminate` checkbox state should be used if the input value is neither true nor false. This can be useful in situations where you are required to display an incomplete state, or one that is dependent on other input selections to determine a value. | ||
|
||
```jsx live | ||
<> | ||
<Box as="form" sx={{p: 3, pt: 0, pb: 1, display: 'flex', alignItems: 'center'}}> | ||
<Checkbox id="indeterminate-checkbox" onChange={() => {}} indeterminate={true} /> | ||
<Text as="label" sx={{fontSize: 2, fontWeight: 'bold', marginLeft: 1}} htmlFor="controlled-checkbox"> | ||
<Text sx={{display: 'block'}}>Default checkbox</Text> | ||
</Text> | ||
</Box> | ||
<Box key={`sub-checkbox-0`} as="form" sx={{p: 1, pl: 6, display: 'flex', alignItems: 'center'}}> | ||
<Checkbox id={`sub-checkbox-0`} checked={true} onChange={() => {}} /> | ||
<Text as="label" sx={{fontSize: 2, fontWeight: 'bold', marginLeft: 1}} htmlFor={`sub-checkbox-0`}> | ||
<Text sx={{display: 'block'}}>Checkbox 1</Text> | ||
</Text> | ||
</Box> | ||
<Box key={`sub-checkbox-1`} as="form" sx={{p: 1, pl: 6, display: 'flex', alignItems: 'center'}}> | ||
<Checkbox id={`sub-checkbox-1`} checked={false} onChange={() => {}} /> | ||
<Text as="label" sx={{fontSize: 2, fontWeight: 'bold', marginLeft: 1}} htmlFor={`sub-checkbox-1`}> | ||
<Text sx={{display: 'block'}}>Checkbox 2</Text> | ||
</Text> | ||
</Box> | ||
<Box key={`sub-checkbox-2`} as="form" sx={{p: 1, pl: 6, display: 'flex', alignItems: 'center'}}> | ||
<Checkbox id={`sub-checkbox-2`} checked={false} onChange={() => {}} /> | ||
<Text as="label" sx={{fontSize: 2, fontWeight: 'bold', marginLeft: 1}} htmlFor={`sub-checkbox-2`}> | ||
<Text sx={{display: 'block'}}>Checkbox 3</Text> | ||
</Text> | ||
</Box> | ||
<Box key={`sub-checkbox-3`} as="form" sx={{p: 1, pl: 6, display: 'flex', alignItems: 'center'}}> | ||
<Checkbox id={`sub-checkbox-3`} checked={false} onChange={() => {}} /> | ||
<Text as="label" sx={{fontSize: 2, fontWeight: 'bold', marginLeft: 1}} htmlFor={`sub-checkbox-3`}> | ||
<Text sx={{display: 'block'}}>Checkbox 4</Text> | ||
</Text> | ||
</Box> | ||
</> | ||
``` | ||
|
||
## Component props | ||
|
||
Native `<input>` attributes are forwarded to the underlying React `input` component and are not listed below. | ||
|
||
| Name | Type | Default | Description | | ||
| :------------- | :---------- | :-------: | :------------------------------------------------------------------------------------------------------------------------------------------------------ | | ||
| checked | Boolean | undefined | Optional. Modifies true/false value of the native checkbox | | ||
| defaultChecked | Boolean | undefined | Optional. Checks the input by default in uncontrolled mode | | ||
| onChange | ChangeEvent | undefined | Optional. A callback function that is triggered when the checked state has been changed. | | ||
| disabled | Boolean | undefined | Optional. Modifies the native disabled state of the native checkbox | | ||
| indeterminate | Boolean | undefined | Optional. Applies an [indeterminate](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/checkbox#attr-indeterminate) state to the checkbox | | ||
|
||
## Component status | ||
|
||
<ComponentChecklist | ||
items={{ | ||
propsDocumented: true, | ||
noUnnecessaryDeps: true, | ||
adaptsToThemes: true, | ||
adaptsToScreenSizes: true, | ||
fullTestCoverage: true, | ||
usedInProduction: false, | ||
usageExamplesDocumented: false, | ||
designReviewed: false, | ||
a11yReviewed: false, | ||
stableApi: false, | ||
addressedApiFeedback: false, | ||
hasDesignGuidelines: false, | ||
hasFigmaComponent: false | ||
}} | ||
/> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,75 @@ | ||
import styled from 'styled-components' | ||
import {useProvidedRefOrCreate} from './hooks' | ||
import React, {InputHTMLAttributes, ReactElement, useLayoutEffect} from 'react' | ||
import sx, {SxProp} from './sx' | ||
|
||
export type CheckboxProps = { | ||
/** | ||
* Apply indeterminate visual appearance to the checkbox | ||
*/ | ||
indeterminate?: boolean | ||
/** | ||
* Apply inactive visual appearance to the checkbox | ||
*/ | ||
disabled?: boolean | ||
/** | ||
* Forward a ref to the underlying input element | ||
*/ | ||
ref?: React.RefObject<HTMLInputElement> | ||
/** | ||
* Indicates whether the checkbox must be checked | ||
*/ | ||
required?: boolean | ||
|
||
/** | ||
* Indicates whether the checkbox validation state | ||
*/ | ||
validationStatus?: 'error' | 'success' // TODO: hoist to Validation typings | ||
} & InputHTMLAttributes<HTMLInputElement> & | ||
SxProp | ||
|
||
const StyledCheckbox = styled.input` | ||
cursor: pointer; | ||
|
||
${props => props.disabled && `cursor: not-allowed;`} | ||
|
||
${sx} | ||
` | ||
|
||
/** | ||
* An accessible, native checkbox component | ||
*/ | ||
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>( | ||
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. Why do we need to use 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. It's there to make the checkbox more flexible when used in uncontrolled mode. |
||
( | ||
{checked, indeterminate, disabled, sx: sxProp, required, validationStatus, ...rest}: CheckboxProps, | ||
ref | ||
): ReactElement => { | ||
const checkboxRef = useProvidedRefOrCreate(ref as React.RefObject<HTMLInputElement>) | ||
|
||
useLayoutEffect(() => { | ||
if (checkboxRef.current) { | ||
checkboxRef.current.indeterminate = indeterminate || false | ||
} | ||
}, [indeterminate, checked, checkboxRef]) | ||
|
||
return ( | ||
<StyledCheckbox | ||
type="checkbox" | ||
disabled={disabled} | ||
aria-disabled={disabled ? 'true' : 'false'} | ||
ref={ref || checkboxRef} | ||
checked={indeterminate ? false : checked} | ||
aria-checked={indeterminate ? 'mixed' : checked ? 'true' : 'false'} | ||
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. Nice |
||
sx={sxProp} | ||
required={required} | ||
aria-required={required ? 'true' : 'false'} | ||
aria-invalid={validationStatus === 'error' ? 'true' : 'false'} | ||
{...rest} | ||
/> | ||
) | ||
} | ||
) | ||
|
||
Checkbox.displayName = 'Checkbox' | ||
|
||
export default Checkbox |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,155 @@ | ||
import React from 'react' | ||
import {Checkbox} from '..' | ||
import {behavesAsComponent, checkExports} from '../utils/testing' | ||
import {render, cleanup} from '@testing-library/react' | ||
import {toHaveNoViolations} from 'jest-axe' | ||
import 'babel-polyfill' | ||
import '@testing-library/jest-dom' | ||
import userEvent from '@testing-library/user-event' | ||
|
||
expect.extend(toHaveNoViolations) | ||
|
||
describe('Checkbox', () => { | ||
beforeEach(() => { | ||
jest.resetAllMocks() | ||
cleanup() | ||
}) | ||
behavesAsComponent({Component: Checkbox}) | ||
|
||
checkExports('Checkbox', { | ||
default: Checkbox | ||
}) | ||
|
||
it('renders a valid checkbox input', () => { | ||
const {getByRole} = render(<Checkbox />) | ||
|
||
const checkbox = getByRole('checkbox') | ||
|
||
expect(checkbox).toBeDefined() | ||
}) | ||
|
||
it('renders an unchecked checkbox by default', () => { | ||
const {getByRole} = render(<Checkbox />) | ||
|
||
const checkbox = getByRole('checkbox') as HTMLInputElement | ||
|
||
expect(checkbox.checked).toEqual(false) | ||
}) | ||
|
||
it('renders an active checkbox when checked attribute is passed', () => { | ||
const handleChange = jest.fn() | ||
const {getByRole} = render(<Checkbox checked onChange={handleChange} />) | ||
|
||
const checkbox = getByRole('checkbox') as HTMLInputElement | ||
|
||
expect(checkbox.checked).toEqual(true) | ||
}) | ||
|
||
it('accepts a change handler that can alter the checkbox state', () => { | ||
const handleChange = jest.fn() | ||
const {getByRole} = render(<Checkbox onChange={handleChange} />) | ||
|
||
const checkbox = getByRole('checkbox') as HTMLInputElement | ||
|
||
expect(checkbox.checked).toEqual(false) | ||
|
||
userEvent.click(checkbox) | ||
expect(handleChange).toHaveBeenCalled() | ||
expect(checkbox.checked).toEqual(true) | ||
|
||
userEvent.click(checkbox) | ||
expect(handleChange).toHaveBeenCalled() | ||
expect(checkbox.checked).toEqual(false) | ||
}) | ||
|
||
it('renders an indeterminate prop correctly', () => { | ||
const handleChange = jest.fn() | ||
const {getByRole} = render(<Checkbox indeterminate checked onChange={handleChange} />) | ||
|
||
const checkbox = getByRole('checkbox') as HTMLInputElement | ||
|
||
expect(checkbox.indeterminate).toEqual(true) | ||
expect(checkbox.checked).toEqual(false) | ||
}) | ||
|
||
it('renders an inactive checkbox state correctly', () => { | ||
const handleChange = jest.fn() | ||
const {getByRole, rerender} = render(<Checkbox disabled onChange={handleChange} />) | ||
|
||
const checkbox = getByRole('checkbox') as HTMLInputElement | ||
|
||
expect(checkbox.disabled).toEqual(true) | ||
expect(checkbox.checked).toEqual(false) | ||
expect(checkbox).toHaveAttribute('aria-disabled', 'true') | ||
|
||
userEvent.click(checkbox) | ||
|
||
expect(checkbox.disabled).toEqual(true) | ||
expect(checkbox.checked).toEqual(false) | ||
expect(checkbox).toHaveAttribute('aria-disabled', 'true') | ||
|
||
// remove disabled attribute and retest | ||
rerender(<Checkbox onChange={handleChange} />) | ||
|
||
expect(checkbox).toHaveAttribute('aria-disabled', 'false') | ||
}) | ||
|
||
it('renders an uncontrolled component correctly', () => { | ||
const {getByRole} = render(<Checkbox defaultChecked />) | ||
|
||
const checkbox = getByRole('checkbox') as HTMLInputElement | ||
|
||
expect(checkbox.checked).toEqual(true) | ||
|
||
userEvent.click(checkbox) | ||
|
||
expect(checkbox.checked).toEqual(false) | ||
}) | ||
|
||
it('renders an aria-checked attribute correctly', () => { | ||
const handleChange = jest.fn() | ||
const {getByRole, rerender} = render(<Checkbox checked={false} onChange={handleChange} />) | ||
|
||
const checkbox = getByRole('checkbox') as HTMLInputElement | ||
|
||
expect(checkbox).toHaveAttribute('aria-checked', 'false') | ||
|
||
rerender(<Checkbox checked={true} onChange={handleChange} />) | ||
|
||
expect(checkbox).toHaveAttribute('aria-checked', 'true') | ||
|
||
rerender(<Checkbox indeterminate checked onChange={handleChange} />) | ||
|
||
expect(checkbox).toHaveAttribute('aria-checked', 'mixed') | ||
}) | ||
|
||
it('renders an invalid aria state when validation prop indicates an error', () => { | ||
const handleChange = jest.fn() | ||
const {getByRole, rerender} = render(<Checkbox onChange={handleChange} />) | ||
|
||
const checkbox = getByRole('checkbox') as HTMLInputElement | ||
|
||
expect(checkbox).toHaveAttribute('aria-invalid', 'false') | ||
|
||
rerender(<Checkbox onChange={handleChange} validationStatus="success" />) | ||
|
||
expect(checkbox).toHaveAttribute('aria-invalid', 'false') | ||
|
||
rerender(<Checkbox onChange={handleChange} validationStatus="error" />) | ||
|
||
expect(checkbox).toHaveAttribute('aria-invalid', 'true') | ||
}) | ||
|
||
it('renders an aria state indicating the field is required', () => { | ||
const handleChange = jest.fn() | ||
const {getByRole, rerender} = render(<Checkbox onChange={handleChange} />) | ||
|
||
const checkbox = getByRole('checkbox') as HTMLInputElement | ||
|
||
expect(checkbox).toHaveAttribute('aria-required', 'false') | ||
|
||
rerender(<Checkbox onChange={handleChange} required />) | ||
|
||
expect(checkbox).toHaveAttribute('aria-required', 'true') | ||
}) | ||
}) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
// Jest Snapshot v1, https://goo.gl/fbAQLP | ||
|
||
exports[`Checkbox renders consistently 1`] = ` | ||
.c0 { | ||
cursor: pointer; | ||
} | ||
|
||
<input | ||
aria-checked="false" | ||
aria-disabled="false" | ||
aria-invalid="false" | ||
aria-required="false" | ||
className="c0" | ||
type="checkbox" | ||
/> | ||
`; |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -169,4 +169,7 @@ export type {TruncateProps} from './Truncate' | |
export {default as UnderlineNav} from './UnderlineNav' | ||
export type {UnderlineNavProps, UnderlineNavLinkProps} from './UnderlineNav' | ||
|
||
export {default as Checkbox} from './Checkbox' | ||
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. Any ideas how we could guard against engineers rendering a I have some ideas, but it would be much easier to show over Zoom instead of text and pseudocode. Let me know if you want to chat on Monday. 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. Chatted with @rezrah about this: we're just going to check for an associated label with a linter instead of doing anything in the runtime. |
||
export type {CheckboxProps} from './Checkbox' | ||
|
||
export {SSRProvider, useSSRSafeId} from './utils/ssr' |
Uh oh!
There was an error while loading. Please reload this page.