-
Notifications
You must be signed in to change notification settings - Fork 31
feat(Checkbox): indeterminate checkboxes #3123
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 12 commits
f4f4743
b4758d1
37f70e5
c45c48e
e0b3d13
84924d3
94496e4
03a03b2
c91c454
d53f887
cb72235
e36a69a
a33965d
076f3a6
cde8a2b
a5137e5
8a51bd3
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 |
|---|---|---|
|
|
@@ -6,7 +6,7 @@ import { | |
| } from '@codecademy/gamut-styles'; | ||
| import { StyleProps } from '@codecademy/variance'; | ||
| import styled from '@emotion/styled'; | ||
| import { forwardRef, InputHTMLAttributes, ReactNode } from 'react'; | ||
| import { forwardRef, InputHTMLAttributes, useEffect, useRef } from 'react'; | ||
|
|
||
| import { | ||
| checkboxElement, | ||
|
|
@@ -19,29 +19,18 @@ import { | |
| polyline, | ||
| } from '../styles'; | ||
| import { BaseInputProps } from '../types'; | ||
| import { CheckboxCheckedUnion, CheckboxLabelUnion } from './types'; | ||
|
|
||
| /** Something will happen here */ | ||
|
||
| export type CheckboxTextProps = StyleProps<typeof checkboxTextStates>; | ||
| export type CheckboxPaddingProps = StyleProps<typeof checkboxPadding>; | ||
|
|
||
| export type CheckboxStringLabelProps = { | ||
| label: string; | ||
| 'aria-label'?: string; | ||
| }; | ||
|
|
||
| export type CheckboxReactNodeLabelProps = { | ||
| label: ReactNode; | ||
| 'aria-label': string; | ||
| }; | ||
|
|
||
| export type CheckboxLabelProps = | ||
| | CheckboxStringLabelProps | ||
| | CheckboxReactNodeLabelProps; | ||
|
|
||
| export type CheckboxProps = Omit< | ||
| InputHTMLAttributes<HTMLInputElement>, | ||
| 'value' | 'label' | 'aria-label' | ||
| 'checked' | 'value' | 'label' | 'aria-label' | ||
| > & | ||
| CheckboxLabelProps & | ||
| CheckboxLabelUnion & | ||
| CheckboxCheckedUnion & | ||
| CheckboxPaddingProps & | ||
| Pick<BaseInputProps, 'name' | 'required'> & { | ||
| multiline?: boolean; | ||
|
|
@@ -78,6 +67,10 @@ export type CheckboxProps = Omit< | |
| */ | ||
| value?: string | boolean; | ||
| id?: string; | ||
| /** | ||
| * Use if you want both the aria-label and text label to be read by voiceover - this component assumes that the aria-label and visual text label are identical. | ||
| * If you have a link in the Checkbox options, you should set this as true. | ||
| */ | ||
| dontAriaHideLabel?: boolean; | ||
| }; | ||
|
|
||
|
|
@@ -88,17 +81,20 @@ const CheckboxLabel = styled.label<Pick<CheckboxProps, 'disabled' | 'spacing'>>( | |
| checkboxLabelStates | ||
| ); | ||
|
|
||
| const CheckboxElement = styled('div', styledOptions)< | ||
| Pick<CheckboxProps, 'checked' | 'multiline' | 'disabled'> | ||
| >(checkboxElement, checkboxElementStates); | ||
| type CheckboxElementProps = StyleProps<typeof checkboxElementStates>; | ||
|
|
||
| const CheckboxElement = styled('div', styledOptions)<CheckboxElementProps>( | ||
| checkboxElement, | ||
| checkboxElementStates | ||
| ); | ||
|
|
||
| const CheckboxVector = styled.svg` | ||
| position: absolute; | ||
| top: -1px; | ||
| left: -1px; | ||
| `; | ||
|
|
||
| const Polyline = styled.polyline<Pick<CheckboxProps, 'checked'>>` | ||
| const Checkmark = styled.polyline<Pick<CheckboxProps, 'checked'>>` | ||
| ${polyline} | ||
| fill: none; | ||
| stroke: currentColor; | ||
|
|
@@ -110,6 +106,16 @@ const Polyline = styled.polyline<Pick<CheckboxProps, 'checked'>>` | |
| transition: stroke-dashoffset ${timing.fast}; | ||
| `; | ||
|
|
||
| const Line = styled.line<Pick<CheckboxProps, 'indeterminate'>>` | ||
| ${polyline} | ||
| fill: none; | ||
| stroke: currentColor; | ||
| stroke-width: 2; | ||
| stroke-dasharray: 18px; | ||
| stroke-dashoffset: ${({ indeterminate }) => (indeterminate ? 0 : `18px`)}; | ||
| transition: stroke-dashoffset ${timing.fast}; | ||
| `; | ||
|
|
||
| const Input = styled.input` | ||
| ${screenReaderOnly} | ||
| ${checkboxInput} | ||
|
|
@@ -121,20 +127,42 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>( | |
| ( | ||
| { | ||
| 'aria-label': ariaLabel, | ||
| checked, | ||
| indeterminate, | ||
| className, | ||
| label, | ||
| disabled, | ||
| dontAriaHideLabel, | ||
| htmlFor, | ||
| multiline, | ||
| id, | ||
| checked, | ||
| disabled, | ||
| label, | ||
| multiline, | ||
| spacing, | ||
| value, | ||
| dontAriaHideLabel, | ||
| ...rest | ||
| }, | ||
| ref | ||
| ) => { | ||
| const intRef = useRef<HTMLInputElement | null>(null); | ||
|
|
||
| function syncedRefs(element: HTMLInputElement | null) { | ||
| intRef.current = element; | ||
| if (ref) { | ||
| if (typeof ref === 'object') { | ||
| ref.current = element; | ||
| } else { | ||
| ref(element); | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+146
to
+155
Contributor
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. Q: when would
Contributor
Author
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. callback refs are sometimes used in animations and other things - here's some react official docs with fancy examples: Manipulating the DOM with Refs |
||
|
|
||
| useEffect(() => { | ||
| if (intRef.current && indeterminate !== undefined) { | ||
| intRef.current.indeterminate = indeterminate; | ||
| } | ||
| }, [indeterminate]); | ||
|
|
||
| const active = checked || indeterminate; | ||
|
|
||
| return ( | ||
| <div className={className}> | ||
| <Input | ||
|
|
@@ -152,27 +180,39 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>( | |
| type="checkbox" | ||
| value={`${value}`} | ||
| {...rest} | ||
| ref={ref} | ||
| ref={syncedRefs} | ||
| /> | ||
| <CheckboxLabel | ||
| disabled={disabled} | ||
| htmlFor={id || htmlFor} | ||
| spacing={spacing} | ||
| > | ||
| <CheckboxElement | ||
| checked={checked} | ||
| active={active} | ||
| disabled={disabled} | ||
| hasBg={checked || indeterminate} | ||
| hideBorder={disabled && (checked || indeterminate)} | ||
| multiline={multiline} | ||
| > | ||
| <CheckboxVector | ||
| aria-hidden | ||
| color={checked ? 'currentColor' : 'transparent'} | ||
| color={active ? 'currentColor' : 'transparent'} | ||
| height="19px" | ||
| viewBox="0 0 19 19" | ||
| width="19px" | ||
| > | ||
| <path d="M1 1h19v19h-19z" fill="currentColor" /> | ||
| <Polyline checked={checked} points="4 11 8 15 16 6" /> | ||
| <Checkmark | ||
| // This should never happen if the types are working, but is a good back-up. | ||
| checked={checked && !indeterminate} | ||
| points="4 11 8 15 16 6" | ||
| /> | ||
| <Line | ||
| indeterminate={indeterminate} | ||
| x1="4" | ||
| x2="16" | ||
| y1="10" | ||
| y2="10" | ||
| /> | ||
| </CheckboxVector> | ||
| </CheckboxElement> | ||
| <CheckboxText | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { ReactNode } from 'react'; | ||
|
|
||
| export type CheckboxStringLabelProps = { | ||
| label: string; | ||
| 'aria-label'?: string; | ||
| }; | ||
|
|
||
| export type CheckboxReactNodeLabelProps = { | ||
| label: ReactNode; | ||
| 'aria-label': string; | ||
| }; | ||
|
|
||
| type CheckboxIndeterminate = { | ||
| indeterminate: boolean; | ||
| checked?: false; | ||
| }; | ||
|
|
||
| type CheckboxRegular = { | ||
| indeterminate?: never; | ||
| checked?: boolean; | ||
| }; | ||
|
|
||
| export type CheckboxCheckedUnion = CheckboxRegular | CheckboxIndeterminate; | ||
|
|
||
| export type CheckboxLabelUnion = | ||
| | CheckboxStringLabelProps | ||
| | CheckboxReactNodeLabelProps; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -79,6 +79,93 @@ const CustomCheckbox: React.FC<CustomCheckboxProps> = ({ | |
|
|
||
| <Canvas of={CheckboxStories.Checked} /> | ||
|
|
||
| ### Indeterminate | ||
|
|
||
| Checkboxes have a third state - `indeterminate` - represented by a dash. | ||
|
|
||
| <Canvas of={CheckboxStories.Indeterminate} /> | ||
|
|
||
| `indeterminate` is used for Checkboxes that are the header of a list of checkboxes. When clicked, all of its child checkboxes should be selected or unselected. Clicking on only some of the child checkboxes should make the header checkbox show the indeterminate state. | ||
|
||
|
|
||
| <Canvas of={CheckboxStories.NestedCheckboxes} /> | ||
|
|
||
| ```tsx | ||
|
||
| const NestedCheckboxExample: React.FC = () => { | ||
| const [childrenChecked, setChildrenChecked] = useState<boolean[]>([ | ||
| false, | ||
| false, | ||
| false, | ||
| ]); | ||
|
|
||
| const allChecked = childrenChecked.every(Boolean); | ||
| const someChecked = childrenChecked.some(Boolean); | ||
|
|
||
| const isIndeterminate = !allChecked && someChecked; | ||
|
|
||
| const toggleAll = () => { | ||
| const next = !allChecked; | ||
| setChildrenChecked(childrenChecked.map(() => next)); | ||
| }; | ||
|
|
||
| const toggleChild = (index: number) => () => { | ||
| setChildrenChecked((prev) => { | ||
| const next = [...prev]; | ||
| next[index] = !prev[index]; | ||
| return next; | ||
| }); | ||
| }; | ||
|
|
||
| return ( | ||
| <Box as="fieldset" border={1} borderRadius="sm" maxWidth="340px" p={16}> | ||
| <legend>My fave Gamut components</legend> | ||
| <Box ml={8}> | ||
| <Checkbox | ||
| htmlFor="nested-parent" | ||
| label="Select all components" | ||
| name="nested-parent" | ||
| onChange={toggleAll} | ||
| {...(isIndeterminate | ||
| ? { indeterminate: true as const, checked: false as const } | ||
| : { checked: allChecked })} | ||
| /> | ||
| </Box> | ||
| <Box as="ul" listStyle="none" ml={32} p={0}> | ||
| {['Boxes', 'ToolTips', 'Pagination'].map((component, i) => ( | ||
| <Box as="li" key={component}> | ||
| <Checkbox | ||
| checked={childrenChecked[i]} | ||
| htmlFor={`nested-child-${i}`} | ||
| label={component} | ||
| name={`nested-child-${i}`} | ||
| onChange={toggleChild(i)} | ||
| /> | ||
| </Box> | ||
| ))} | ||
| </Box> | ||
| </Box> | ||
| ); | ||
| }; | ||
| ``` | ||
|
|
||
| There are several accessibility considerations to take when using indeterminate checkboxes. | ||
|
|
||
| #### Accessibility considerations | ||
|
|
||
| - **Programmatically set the `indeterminate` property** | ||
dreamwasp marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| The mixed state is a JavaScript-only property on the native `<input type="checkbox">`. When you set `indeterminate` on the Gamut `Checkbox`, the component applies the underlying DOM property for you, but you must update this prop whenever the selection state of the child checkboxes changes so that assistive technologies receive an accurate announcement. | ||
|
|
||
| - **Keep `checked` and `indeterminate` mutually exclusive** | ||
| There are Typescript types that guard against this behavior but keep in mind this shouldn't happen. | ||
|
|
||
| - **Group related checkboxes with `fieldset` & `legend`** | ||
| Wrapping the parent and its children in a `<fieldset>` with a `<legend>` communicates their relationship to screen-reader users. | ||
|
|
||
| - **Use a descriptive label** | ||
| The parent checkbox's label should describe what will be selected, e.g. "Select all lessons", so the purpose is clear when a screen reader announces "mixed". | ||
|
|
||
| - **Nested checkboxes should be in a list** | ||
| The checkboxs should be wrapped in a `ul` and `li` components as shown in the example above. | ||
dreamwasp marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| ### Disabled | ||
|
|
||
| <Canvas of={CheckboxStories.Disabled} /> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would it help to also check that
checkedis alsofalse(or!true? falsey?) here whenindeterminateistrue?E.g. as a safeguard in case types get changed?