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