Skip to content
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

Add indeterminate prop to the Checkbox #1415

Merged
merged 14 commits into from
May 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-toes-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sumup/circuit-ui': minor
---

Added the `indeterminate` prop to the Checkbox component. Use it to display and control the collective state of a group of nested checkboxes.
15 changes: 15 additions & 0 deletions packages/circuit-ui/components/Checkbox/Checkbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,21 @@ Disabled form fields can be confusing to users, so use them with caution. Consid

<Story of={Stories.Disabled} />

### Indeterminate

The indeterminate state of a checkbox can be used to display and control the collective state of a group of nested checkboxes.

A checkbox is a binary input: either it is checked or it is not. A group of checkboxes, however, can be thought of as having three states: all unchecked, all checked or some checked. The mixed state can be represented by the `indeterminate` prop. It is purely presentational and should not be relied upon to submit data since a native form submission won't include an indeterminate checkbox.

When building a checkbox group with a parent checkbox, make sure to adhere to the following best practices:

- The checkbox group should be wrapped in a `fieldset` and labelled using the `legend` element.
- Validation messages should be associated with the `fieldset` using `aria-describedby` and should be announced to screen readers using a live region.
- Toggling the state of the parent checkbox should toggle the state of all child options to match the parent's state.
- Toggling the state of a child option should update the parent's state.

<Story of={Stories.Indeterminate} />

## State

The Checkbox can be used as a controlled or uncontrolled component.
Expand Down
7 changes: 7 additions & 0 deletions packages/circuit-ui/components/Checkbox/Checkbox.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ describe('Checkbox', () => {
expect(inputEl).toBeDisabled();
});

it('should be optionally indeterminate', () => {
render(<Checkbox {...defaultProps} indeterminate />);
const inputEl: HTMLInputElement = screen.getByRole('checkbox');
expect(inputEl.indeterminate).toBe(true);
expect(inputEl).toHaveAttribute('aria-checked', 'mixed');
});

it('should have a name', () => {
render(<Checkbox {...defaultProps} />);
const inputEl = screen.getByRole('checkbox');
Expand Down
97 changes: 96 additions & 1 deletion packages/circuit-ui/components/Checkbox/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
* limitations under the License.
*/

import { useState } from 'react';
import { ChangeEvent, useState } from 'react';
import { css } from '@emotion/react';
import type { Theme } from '@sumup/design-tokens';

import { Checkbox, CheckboxProps } from './Checkbox';

Expand Down Expand Up @@ -69,3 +71,96 @@ Disabled.args = {
validationHint: 'Express shipping is unavailable in your region',
disabled: true,
};

const legendStyles = (theme: Theme) => css`
display: block;
margin-bottom: ${theme.spacings.bit};
font-size: ${theme.typography.body.two.fontSize};
line-height: ${theme.typography.body.two.lineHeight};
`;

const listStyles = css`
list-style: none;
margin-left: 26px;
`;

export const Indeterminate = (args: {
label: string;
name: string;
initialValues: CheckboxProps['value'][];
parent: CheckboxProps;
options: CheckboxProps[];
}) => {
const { label, name, initialValues, parent, options } = args;
const [values, setValues] = useState(initialValues);

const handleOptionChange = (event: ChangeEvent<HTMLInputElement>) => {
const eventValue = event.target.value;

setValues((prevValues) =>
prevValues.includes(eventValue)
? prevValues.filter((v) => v !== eventValue)
: prevValues.concat(eventValue),
);
};

const handleParentChange = () => {
setValues((prevValues) =>
prevValues.length === options.length
? []
: options.map((option) => option.value),
);
};

const someChecked = options.some((option) => values.includes(option.value));
const allChecked = options.every((option) => values.includes(option.value));

return (
<fieldset name={name}>
<legend css={legendStyles}>{label}</legend>
<Checkbox
{...parent}
onChange={handleParentChange}
name={name}
indeterminate={someChecked && !allChecked}
checked={allChecked}
/>
<ul css={listStyles}>
{options.map((option) => (
<li key={option.label}>
<Checkbox
{...option}
onChange={handleOptionChange}
name={name}
checked={values.includes(option.value)}
/>
</li>
))}
</ul>
</fieldset>
);
};

Indeterminate.args = {
label: 'Choose your favorite fruits',
name: 'indeterminate',
initialValues: ['mango'],
parent: {
label: 'All fruits',
value: 'all',
},
options: [
{
label: 'Apple',
value: 'apple',
},
{
label: 'Banana',
value: 'banana',
},
{
label: 'Mango',
value: 'mango',
},
],
};
77 changes: 57 additions & 20 deletions packages/circuit-ui/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* limitations under the License.
*/

import { InputHTMLAttributes, Ref, forwardRef } from 'react';
import { InputHTMLAttributes, forwardRef, useEffect, useRef } from 'react';
import { css } from '@emotion/react';
import { Checkmark } from '@sumup/icons';

Expand All @@ -24,16 +24,24 @@ import { useClickEvent, TrackingProps } from '../../hooks/useClickEvent';
import { FieldValidationHint, FieldWrapper } from '../FieldAtoms';
import { deprecate } from '../../util/logger';
import { AccessibilityError } from '../../util/errors';
import { applyMultipleRefs } from '../../util/refs';

import { IndeterminateIcon } from './IndeterminateIcon';

export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
/**
* A clear and concise description of the input's purpose.
*/
label?: string;
/**
* Triggers error styles on the component.
* Marks the input as invalid.
*/
invalid?: boolean;
/**
* Marks the input as indeterminate. This is presentational only, the value
* of an indeterminate checkbox is not included in form submissions.
*/
indeterminate?: boolean;
/**
* An information or error message, displayed below the checkbox.
*/
Expand All @@ -44,10 +52,6 @@ export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
* Use an `onChange` handler to dispatch user interaction events instead.
*/
tracking?: TrackingProps;
/**
* The ref to the HTML DOM element.
*/
ref?: Ref<HTMLInputElement>;
/**
* @deprecated
*
Expand Down Expand Up @@ -126,19 +130,30 @@ const inputBaseStyles = ({ theme }: StyleProps) => css`
border-color: var(--cui-border-normal);
}

&:checked:focus:not(:focus-visible) + label::before {
&:checked:focus:not(:focus-visible) + label::before,
&:indeterminate:focus:not(:focus-visible) + label::before {
border-color: var(--cui-border-accent);
}

&:checked + label > svg {
&:checked:not(:indeterminate) + label > svg[data-symbol='checked'],
&:indeterminate + label > svg[data-symbol='indeterminate'] {
transform: translateY(-50%) scale(1, 1);
opacity: 1;
}

&:checked + label::before {
&:checked + label::before,
&:indeterminate + label::before {
border-color: var(--cui-border-accent);
background-color: var(--cui-bg-accent-strong);
}

&:checked:disabled + label::before,
&:checked[disabled] + label::before,
&:indeterminate:disabled + label::before,
&:indeterminate[disabled] + label::before {
border-color: var(--cui-border-accent-disabled);
background-color: var(--cui-bg-accent-strong-disabled);
}
`;

const inputInvalidStyles = ({ invalid }: InputElProps) =>
Expand All @@ -154,10 +169,19 @@ const inputInvalidStyles = ({ invalid }: InputElProps) =>
border-color: var(--cui-border-danger-hovered);
}

&:checked + label::before {
&:checked + label::before,
&:indeterminate + label::before {
border-color: var(--cui-border-danger);
background-color: var(--cui-bg-danger-strong);
}

&:checked:disabled + label::before,
&:indeterminate:disabled + label::before,
&:checked[disabled] + label::before,
&:indeterminate[disabled] + label::before {
border-color: var(--cui-border-danger-disabled);
background-color: var(--cui-bg-danger-strong-disabled);
}
`;

const inputDisabledStyles = () =>
Expand All @@ -166,11 +190,11 @@ const inputDisabledStyles = () =>
&[disabled] + label {
pointer-events: none;
color: var(--cui-fg-normal-disabled);

&::before {
border-color: var(--cui-border-normal-disabled);
background-color: var(--cui-bg-normal-disabled);
}
}
&:disabled + label::before,
&[disabled] + label::before {
border-color: var(--cui-border-normal-disabled);
background-color: var(--cui-bg-normal-disabled);
}

&:disabled:checked + label::before,
Expand All @@ -189,7 +213,7 @@ const CheckboxInput = styled('input')<InputElProps>(
/**
* Checkbox component for forms.
*/
export const Checkbox = forwardRef(
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
(
{
onChange,
Expand All @@ -204,11 +228,21 @@ export const Checkbox = forwardRef(
style,
invalid,
tracking,
indeterminate = false,
'aria-describedby': descriptionId,
...props
}: CheckboxProps,
ref: CheckboxProps['ref'],
},
passedRef,
) => {
const localRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (localRef.current) {
localRef.current.indeterminate = indeterminate;
}
// Because it came from a props, we are keeping the `indeterminate` state even if the `checked` one is changed:
}, [props.checked, indeterminate]);

if (process.env.NODE_ENV !== 'production' && children) {
deprecate(
'Checkbox',
Expand All @@ -230,6 +264,7 @@ export const Checkbox = forwardRef(
const descriptionIds = `${
descriptionId ? `${descriptionId} ` : ''
}${validationHintId}`;

const handleChange = useClickEvent(onChange, tracking, 'checkbox');

return (
Expand All @@ -242,13 +277,15 @@ export const Checkbox = forwardRef(
type="checkbox"
disabled={disabled}
invalid={invalid}
ref={ref}
ref={applyMultipleRefs(passedRef, localRef)}
aria-describedby={descriptionIds}
onChange={handleChange}
aria-checked={indeterminate ? 'mixed' : undefined}
/>
<CheckboxLabel htmlFor={id}>
{label || children}
<Checkmark aria-hidden="true" />
<Checkmark aria-hidden="true" data-symbol="checked" />
<IndeterminateIcon aria-hidden="true" data-symbol="indeterminate" />
</CheckboxLabel>
<FieldValidationHint
id={validationHintId}
Expand Down
29 changes: 29 additions & 0 deletions packages/circuit-ui/components/Checkbox/IndeterminateIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Copyright 2022, SumUp Ltd.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import type { SVGAttributes, ReactElement } from 'react';

export const IndeterminateIcon = (
props: SVGAttributes<SVGElement>,
): ReactElement => (
<svg
viewBox="0 0 16 16"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<rect width="10" height="2" x="3" y="7" rx="1" />
</svg>
);
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ The CheckboxGroup can be used as a controlled or uncontrolled component.

```tsx
import { useState, ChangeEvent } from 'react';
import { CheckboxGroup } from '@sumup/circuit-ui';
import { CheckboxGroup, CheckboxProps } from '@sumup/circuit-ui';

function Controlled() {
const [value, setValue] = useState([]);
const [value, setValue] = useState<CheckboxProps['value'][]>([]);

const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const eventValue = event.target.value;
Expand Down