diff --git a/CHANGELOG.md b/CHANGELOG.md
index 614999d27..a26b1e738 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
+
+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)
diff --git a/package-lock.json b/package-lock.json
index 63b54c362..056a8c7f7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@openstax/ui-components",
- "version": "1.25.0",
+ "version": "1.26.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openstax/ui-components",
- "version": "1.25.0",
+ "version": "1.26.0",
"license": "MIT",
"dependencies": {
"@sentry/react": "^10.60.0",
diff --git a/package.json b/package.json
index a37ec9a7f..c58ffdb90 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/components/Checkbox/Checkbox.css b/src/components/Checkbox/Checkbox.css
index 8397cb454..88af621f7 100644
--- a/src/components/Checkbox/Checkbox.css
+++ b/src/components/Checkbox/Checkbox.css
@@ -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;
}
diff --git a/src/components/Checkbox/Checkbox.spec.tsx b/src/components/Checkbox/Checkbox.spec.tsx
index 3b1c9dfb9..993e9f91a 100644
--- a/src/components/Checkbox/Checkbox.spec.tsx
+++ b/src/components/Checkbox/Checkbox.spec.tsx
@@ -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';
@@ -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(Click Me);
+
+ expect(input().indeterminate).toBe(false);
+ expect(icon()).toBe('');
+ });
+
+ it('sets the mixed state without changing checked', () => {
+ render(Click Me);
+
+ expect(input().indeterminate).toBe(true);
+ expect(input().checked).toBe(false);
+ });
+
+ it('follows the prop in both directions', () => {
+ const { rerender } = render(Click Me);
+ rerender(Click Me);
+ expect(input().indeterminate).toBe(false);
+
+ rerender(Click Me);
+ expect(input().indeterminate).toBe(true);
+ });
+
+ it('stays mixed after a click that does not change the prop', () => {
+ render(Click Me);
+
+ fireEvent.click(input());
+
+ expect(input().indeterminate).toBe(true);
+ });
+
+ it('calls the onClick it was given', () => {
+ const onClick = jest.fn();
+ render(Click Me);
+
+ 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 setMixed(false)}>Click Me;
+ };
+ render();
+
+ 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(Click Me);
+
+ expect(icon()).toContain('url(');
+ expect(input().style.getPropertyValue('--checkbox-indeterminate-bg')).not.toBe('');
+ });
+
+ it('draws no dash when disabled', () => {
+ render(Click Me);
+
+ expect(input().indeterminate).toBe(true);
+ expect(icon()).toBe('none');
+ });
+ });
});
diff --git a/src/components/Checkbox/Checkbox.stories.tsx b/src/components/Checkbox/Checkbox.stories.tsx
index 248c2190b..076fe40f5 100644
--- a/src/components/Checkbox/Checkbox.stories.tsx
+++ b/src/components/Checkbox/Checkbox.stories.tsx
@@ -1,3 +1,4 @@
+import React from "react";
import styled from "styled-components";
import { Checkbox } from "./Checkbox";
@@ -46,3 +47,47 @@ export const Disabled = () => <>
{renderDisabledCheckboxes('light', 1.6)}
{renderDisabledCheckboxes('light', 2)}
>
+
+const renderIndeterminateCheckboxes = (variant: CheckboxProps['variant'], size: CheckboxProps['size']) =>
+ {variant} - Size {size}
+ Checkbox label
+ Checkbox label
+ Checkbox label
+;
+
+export const Indeterminate = () => <>
+ {(['primary', 'light', 'error'] as const).flatMap(variant =>
+ ([1.4, 1.6, 1.8, 2] as const).map(size =>
+ {renderIndeterminateCheckboxes(variant, size)}
+ )
+ )}
+>;
+
+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(['Option two']);
+ const all = selected.length === options.length;
+
+ return
+ 0 && !all}
+ onChange={() => setSelected(all ? [] : options)}
+ >
+ All options
+
+ {options.map(option => setSelected(event.target.checked
+ ? [...selected, option]
+ : selected.filter(item => item !== option))}
+ >
+ {option}
+ )}
+ ;
+};
diff --git a/src/components/Checkbox/Checkbox.tsx b/src/components/Checkbox/Checkbox.tsx
index 15ab63c65..e7a7bd490 100644
--- a/src/components/Checkbox/Checkbox.tsx
+++ b/src/components/Checkbox/Checkbox.tsx
@@ -1,5 +1,4 @@
-import type React from "react";
-import { LabelHTMLAttributes, PropsWithChildren } from "react";
+import React, { LabelHTMLAttributes, PropsWithChildren, useEffect, useLayoutEffect, useRef } from "react";
import { checkboxVariants, CheckboxVariant, CheckboxSize } from "./sharedCheckboxStyles";
import { InputHTMLAttributes } from "react";
import classNames from "classnames";
@@ -8,12 +7,27 @@ import "./Checkbox.css";
type CheckboxProps = PropsWithChildren<
Omit, '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;
}>;
-export const Checkbox = ({ children, disabled, variant = 'primary', bold = false, size = 1.6, labelProps, className, style, ...props }: CheckboxProps) => {
+// useLayoutEffect warns when rendered on the server.
+const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
+
+export const Checkbox = ({ children, disabled, variant = 'primary', bold = false, size = 1.6, labelProps, className, style, indeterminate = false, onClick, ...props }: CheckboxProps) => {
+ const inputRef = useRef(null);
+
+ // `indeterminate` is a DOM property with no attribute, so it is applied after every render,
+ // before paint, so the box never shows the wrong state.
+ useIsomorphicLayoutEffect(() => {
+ if (inputRef.current) {
+ inputRef.current.indeterminate = indeterminate;
+ }
+ });
+
// Get variant styles for CSS variables
const variantStyles = disabled ? checkboxVariants.disabled : checkboxVariants[variant];
@@ -46,6 +60,12 @@ 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;
@@ -53,6 +73,13 @@ export const Checkbox = ({ children, disabled, variant = 'primary', bold = false