Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

#### `Checkbox` supports an `indeterminate` state (CORE-2901)
Comment thread
RoyEJohnson marked this conversation as resolved.

A checkbox that summarises a set of others, such as "all options" above a list, has no way
to say that only some of them are chosen. It was either checked or unchecked, so assistive
tech reported a fully checked box when it was not (WCAG 4.1.2, Name, Role, Value).

`Checkbox` takes a new `indeterminate` prop. It sets the input's `indeterminate` property,
which browsers expose as the "mixed" state, and draws a dash in place of the checkmark for
each variant. It does not change `checked`, so the parent decides what a click on a mixed
box does; selecting everything is the native behaviour. The browser clears `indeterminate`
on click, so the prop is reapplied after every render and stays the source of truth.

A disabled indeterminate checkbox draws no dash, the same as a disabled checked one.

### Fixed

#### The Help menu no longer announces "menu" twice (CORE-2940)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openstax/ui-components",
"version": "1.25.0",
"version": "1.26.0",
"license": "MIT",
"repository": "https://github.com/openstax/ui-components.git",
"publishConfig": {
Expand Down
9 changes: 8 additions & 1 deletion src/components/Checkbox/Checkbox.css
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,19 @@
opacity: var(--checkbox-checked-opacity, 1);
}

.checkbox-input:indeterminate::before {
transform: scale(1);
background-color: var(--checkbox-indeterminate-bg, #026AA1);
background-image: var(--checkbox-indeterminate-icon, none);
}

.checkbox-input--disabled {
opacity: 0.4;
border: var(--checkbox-disabled-border, 1px solid #d5d5d5);
}

.checkbox-input--disabled:checked::before {
.checkbox-input--disabled:checked::before,
.checkbox-input--disabled:indeterminate::before {
opacity: 0;
}

Expand Down
74 changes: 74 additions & 0 deletions src/components/Checkbox/Checkbox.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { Checkbox } from './Checkbox';
import renderer from 'react-test-renderer';

Expand Down Expand Up @@ -30,4 +32,76 @@ describe('Checkbox', () => {
).toJSON();
expect(tree).toMatchSnapshot();
});

describe('indeterminate', () => {
const input = () => screen.getByRole('checkbox') as HTMLInputElement;
const icon = () => input().style.getPropertyValue('--checkbox-indeterminate-icon');

it('is not indeterminate by default', () => {
render(<Checkbox>Click Me</Checkbox>);

expect(input().indeterminate).toBe(false);
expect(icon()).toBe('');
});

it('sets the mixed state without changing checked', () => {
render(<Checkbox indeterminate>Click Me</Checkbox>);

expect(input().indeterminate).toBe(true);
expect(input().checked).toBe(false);
});

it('follows the prop in both directions', () => {
const { rerender } = render(<Checkbox indeterminate>Click Me</Checkbox>);
rerender(<Checkbox indeterminate={false}>Click Me</Checkbox>);
expect(input().indeterminate).toBe(false);

rerender(<Checkbox indeterminate>Click Me</Checkbox>);
expect(input().indeterminate).toBe(true);
});

it('stays mixed after a click that does not change the prop', () => {
render(<Checkbox indeterminate onChange={jest.fn()}>Click Me</Checkbox>);

fireEvent.click(input());

expect(input().indeterminate).toBe(true);
});

it('calls the onClick it was given', () => {
const onClick = jest.fn();
render(<Checkbox indeterminate onClick={onClick} onChange={jest.fn()}>Click Me</Checkbox>);

fireEvent.click(input());

expect(onClick).toHaveBeenCalledTimes(1);
});

it('lets the parent clear it in response to the click', () => {
const Parent = () => {
const [mixed, setMixed] = React.useState(true);
return <Checkbox indeterminate={mixed} checked={!mixed} onChange={() => setMixed(false)}>Click Me</Checkbox>;
};
render(<Parent />);

fireEvent.click(input());

expect(input().indeterminate).toBe(false);
expect(input().checked).toBe(true);
});

it.each(['primary', 'light', 'error'] as const)('draws a dash for the %s variant', (variant) => {
render(<Checkbox indeterminate variant={variant}>Click Me</Checkbox>);

expect(icon()).toContain('url(');
expect(input().style.getPropertyValue('--checkbox-indeterminate-bg')).not.toBe('');
});

it('draws no dash when disabled', () => {
render(<Checkbox indeterminate disabled>Click Me</Checkbox>);

expect(input().indeterminate).toBe(true);
expect(icon()).toBe('none');
});
});
});
45 changes: 45 additions & 0 deletions src/components/Checkbox/Checkbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from "react";
import styled from "styled-components";
import { Checkbox } from "./Checkbox";

Expand Down Expand Up @@ -46,3 +47,47 @@ export const Disabled = () => <>
{renderDisabledCheckboxes('light', 1.6)}
{renderDisabledCheckboxes('light', 2)}
</>

const renderIndeterminateCheckboxes = (variant: CheckboxProps['variant'], size: CheckboxProps['size']) => <CheckboxGroup>
<h2>{variant} - Size {size}</h2>
<Checkbox {...{size, variant}} indeterminate>Checkbox label</Checkbox>
<Checkbox {...{size, variant}} indeterminate bold>Checkbox label</Checkbox>
<Checkbox {...{size, variant}} indeterminate disabled>Checkbox label</Checkbox>
</CheckboxGroup>;

export const Indeterminate = () => <>
{(['primary', 'light', 'error'] as const).flatMap(variant =>
([1.4, 1.6, 1.8, 2] as const).map(size =>
<React.Fragment key={`${variant}-${size}`}>{renderIndeterminateCheckboxes(variant, size)}</React.Fragment>
)
)}
</>;
Comment thread
RoyEJohnson marked this conversation as resolved.

const options = ['Option one', 'Option two', 'Option three'];

// The parent is mixed while only some options are chosen, and selects all from the mixed state.
export const ParentOfOptions = () => {
const [selected, setSelected] = React.useState<string[]>(['Option two']);
const all = selected.length === options.length;

return <CheckboxGroup>
<Checkbox
bold
size={2}
checked={all}
indeterminate={selected.length > 0 && !all}
onChange={() => setSelected(all ? [] : options)}
>
All options
</Checkbox>
{options.map(option => <Checkbox
key={option}
checked={selected.includes(option)}
onChange={event => setSelected(event.target.checked
? [...selected, option]
: selected.filter(item => item !== option))}
>
{option}
</Checkbox>)}
</CheckboxGroup>;
};
29 changes: 26 additions & 3 deletions src/components/Checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type React from "react";
import { LabelHTMLAttributes, PropsWithChildren } from "react";
import React, { LabelHTMLAttributes, PropsWithChildren, useEffect, useRef } from "react";
import { checkboxVariants, CheckboxVariant, CheckboxSize } from "./sharedCheckboxStyles";
import { InputHTMLAttributes } from "react";
import classNames from "classnames";
Expand All @@ -8,12 +7,23 @@ import "./Checkbox.css";
type CheckboxProps = PropsWithChildren<
Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> & {
variant?: CheckboxVariant;
/** Exposes the mixed state, for a checkbox that summarises a set of others. Does not change `checked`. */
indeterminate?: boolean;
size?: CheckboxSize;
bold?: boolean;
labelProps?: LabelHTMLAttributes<HTMLLabelElement>;
}>;

export const Checkbox = ({ children, disabled, variant = 'primary', bold = false, size = 1.6, labelProps, className, style, ...props }: CheckboxProps) => {
export const Checkbox = ({ children, disabled, variant = 'primary', bold = false, size = 1.6, labelProps, className, style, indeterminate = false, onClick, ...props }: CheckboxProps) => {
const inputRef = useRef<HTMLInputElement>(null);

// `indeterminate` is a DOM property with no attribute, so it is applied after every render.
useEffect(() => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apparently this might have to be a useLayoutEffect to prevent the checkbox state flashing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 4eddd79. The effect is now a layout effect, so the property is set before paint. It falls back to useEffect when there is no window, because React warns about useLayoutEffect during server rendering.

if (inputRef.current) {
inputRef.current.indeterminate = indeterminate;
}
});
Comment thread
RoyEJohnson marked this conversation as resolved.
Outdated

// Get variant styles for CSS variables
const variantStyles = disabled ? checkboxVariants.disabled : checkboxVariants[variant];

Expand Down Expand Up @@ -46,13 +56,26 @@ export const Checkbox = ({ children, disabled, variant = 'primary', bold = false
'--checkbox-border-checked': variantStyles.checkedBorder,
'--checkbox-checkmark': variantStyles.backgroundImage === 'none' ? 'none' : `url('${variantStyles.backgroundImage}')`,
'--checkbox-opacity': disabled ? '0.4' : '1',
...(indeterminate ? {
'--checkbox-indeterminate-bg': variantStyles.backgroundColor,
'--checkbox-indeterminate-icon': variantStyles.indeterminateImage === 'none'
? 'none'
: `url('${variantStyles.indeterminateImage}')`,
} : {}),
...style
} as unknown as React.CSSProperties;

return (
<label {...labelProps} className={labelClassName} style={labelStyle}>
<input
{...props}
ref={inputRef}
onClick={indeterminate ? (event) => {
onClick?.(event);
// The browser clears it on click, before any render. Put it back so it follows the prop
// even when the click does not cause one.
event.currentTarget.indeterminate = true;
} : onClick}
type="checkbox"
className={inputClassName}
style={inputStyle}
Expand Down
16 changes: 11 additions & 5 deletions src/components/Checkbox/sharedCheckboxStyles.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { colors } from "../../theme";
import { whiteCheckmark, grayCheckmark, redCheckmark } from "../svgs/checkmarksvgs";
import {
whiteCheckmark, grayCheckmark, redCheckmark, checkedMixIcon, grayMixIcon, redMixIcon
} from "../svgs/checkmarksvgs";

export type CheckboxVariant = keyof typeof checkboxVariants;
export type CheckboxSize = 1.4 | 1.6 | 1.8 | 2;
Expand All @@ -14,27 +16,31 @@ export const checkboxVariants = {
color: 'inherit',
unCheckedBorder: `1px solid ${colors.palette.neutralThin}`,
checkedBorder: `1px solid ${colors.palette.mediumBlue}`,
backgroundImage: whiteCheckmark
backgroundImage: whiteCheckmark,
indeterminateImage: checkedMixIcon
},
light: {
backgroundColor: colors.palette.white,
color: 'inherit',
unCheckedBorder: `1px solid ${colors.palette.pale}`,
checkedBorder: `1px solid ${colors.palette.pale}`,
backgroundImage: grayCheckmark
backgroundImage: grayCheckmark,
indeterminateImage: grayMixIcon
},
error: {
backgroundColor: colors.palette.paleRed,
color: colors.palette.darkRed,
unCheckedBorder: `1px solid ${colors.palette.lightRed}`,
checkedBorder: `1px solid ${colors.palette.lightRed}`,
backgroundImage: redCheckmark
backgroundImage: redCheckmark,
indeterminateImage: redMixIcon
},
disabled: {
backgroundColor: colors.palette.white,
color: 'inherit',
unCheckedBorder: `1px solid ${colors.palette.pale}`,
checkedBorder: `1px solid ${colors.palette.pale}`,
backgroundImage: 'none'
backgroundImage: 'none',
indeterminateImage: 'none'
}
} as const;
5 changes: 4 additions & 1 deletion src/components/svgs/checkmarksvgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,7 @@ export const grayCheckmark = 'data:image/svg+xml,<svg height="125px" width="125p

export const redCheckmark = 'data:image/svg+xml,<svg height="125px" width="125px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 17.837 17.837" xml:space="preserve" fill="%23000000"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><g><path style="fill:%23C22032;" d="M16.145,2.571c-0.272-0.273-0.718-0.273-0.99,0L6.92,10.804l-4.241-4.27 c-0.272-0.274-0.715-0.274-0.989,0L0.204,8.019c-0.272,0.271-0.272,0.717,0,0.99l6.217,6.258c0.272,0.271,0.715,0.271,0.99,0 L17.63,5.047c0.276-0.273,0.276-0.72,0-0.994L16.145,2.571z"></path></g></g></svg>'

export const checkedMixIcon = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 4" width="12" height="4"><path d="M10.5.965a.75.75 0 0 1 .75.75v.75a.75.75 0 0 1-.75.75H1.5a.75.75 0 0 1-.75-.75v-.75a.75.75 0 0 1 .75-.75Z" fill="%23fff"/></svg>'
export const checkedMixIcon = 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 4" width="12" height="4"><path d="M10.5.965a.75.75 0 0 1 .75.75v.75a.75.75 0 0 1-.75.75H1.5a.75.75 0 0 1-.75-.75v-.75a.75.75 0 0 1 .75-.75Z" fill="%23fff"/></svg>'
export const grayMixIcon = checkedMixIcon.replace('%23fff', '%235e5e5e');

export const redMixIcon = checkedMixIcon.replace('%23fff', '%23C22032');
Loading