diff --git a/packages/shared/src/components/fields/Checkbox.module.css b/packages/shared/src/components/fields/Checkbox.module.css index 8676d8c232b..0ff2d8ce19c 100644 --- a/packages/shared/src/components/fields/Checkbox.module.css +++ b/packages/shared/src/components/fields/Checkbox.module.css @@ -1,4 +1,25 @@ +/* + * Checkbox — daily.dev design system. + * Aligned with the redesigned Toggle/Switch and Button system so fields, + * buttons, toggles, checkboxes and radios read as one family: + * - a single semantic brand fill when selected (accent-cabbage-default) with a + * solid white glyph, identical in light & dark (no per-theme overrides); + * - a soft surface-hover halo behind the box on hover; + * - a surface-focus (blueCheese) keyboard ring on :focus-visible; + * - a subtle press squeeze for tactile feedback (à la the Switch knob); + * - a tri-state "indeterminate" dash (aria-checked="mixed"). + * Motion is disabled under prefers-reduced-motion (see bottom). + */ + .checkmark { + background: transparent; + /* Resting outline matches the label tone (text-secondary); hover/focus lift + * it to text-primary, mirroring the field's resting→focus border. */ + border-color: var(--theme-text-secondary); + transition: background-color 0.12s linear, border-color 0.12s linear, + box-shadow 0.12s linear, transform 0.2s cubic-bezier(0.16, 1, 0.3, 1); + + /* Soft surface halo behind the box; the offset trick centers a 2rem square. */ &:before { content: ''; position: absolute; @@ -10,94 +31,113 @@ height: 2rem; margin: auto; border-radius: 0.625rem; + background: var(--theme-surface-hover); opacity: 0; - transition: background-color 0.1s linear, opacity 0.1s linear; + transition: background-color 0.12s linear, opacity 0.12s linear; pointer-events: none; z-index: -1; } } +/* The check glyph stays solid white in every theme/state for contrast. */ +.checkmark :global(.icon) { + color: theme('colors.raw.salt.0'); + transition: opacity 0.12s linear; +} + +/* Indeterminate dash — a centered white bar, shown for the "mixed" state. */ +.dash { + position: absolute; + inset: 0; + margin: auto; + width: 0.625rem; + height: 0.125rem; + border-radius: 9999px; + background: theme('colors.raw.salt.0'); + opacity: 0; + transition: opacity 0.12s linear; + pointer-events: none; +} + .label { - &:global(.disabled), &:global(.checked.disabled) { + &:global(.disabled), + &:global(.checked.disabled), + &:global(.indeterminate.disabled) { color: var(--theme-text-disabled); } - &:global(.checked) { + /* Selected (checked or indeterminate) lifts the label to primary. */ + &:global(.checked), + &:global(.indeterminate) { color: var(--theme-text-primary); - - & .checkmark { - & :global(.icon) { - opacity: 1; - } - } } - /* &:hover, */ - &:hover:not(:global(.disabled)), - &:focus-within:not(:global(.disabled)) { - & .checkmark { - border-color: var(--theme-text-primary); - - &:before { - background: var(--theme-surface-hover); - opacity: 1; - } - } + /* Show the check only when checked and NOT indeterminate. */ + &:global(.checked):not(:global(.indeterminate)) .checkmark :global(.icon) { + opacity: 1; } - &:active { - & .checkmark:before { - background: var(--theme-active); - } + /* Show the dash for the indeterminate state. */ + &:global(.indeterminate) .checkmark .dash { + opacity: 1; } - &:global(.checked) { - & .checkmark { - background: theme('colors.raw.cabbage.40'); - border-color: transparent; - } + /* Hover / keyboard focus lift the border and reveal the halo. */ + &:hover:not(:global(.disabled)) .checkmark, + & input:focus-visible ~ .checkmark { + border-color: var(--theme-text-primary); - &:global(.disabled) { - & .checkmark { - background: var(--theme-text-disabled); - } + &:before { + opacity: 1; } + } - &:hover:not(:global(.disabled)), - &:focus-within:not(:global(.disabled)) { - & .checkmark { - background: theme('colors.raw.cabbage.20'); + /* Keyboard focus — an even blue ring (matches Switch / fields v2). */ + & input:focus-visible ~ .checkmark { + box-shadow: 0 0 0 2px var(--theme-surface-focus); + } - &:before { - background: theme('colors.overlay.quaternary.cabbage'); - } - } - } + /* Press — a subtle squeeze for tactile feedback. */ + &:active:not(:global(.disabled)) .checkmark { + transform: scale(0.9); + } - &:active { - & .checkmark:before { - background: theme('colors.overlay.tertiary.cabbage'); - } - } + /* Selected — solid brand fill, identical in light & dark. */ + &:global(.checked) .checkmark, + &:global(.indeterminate) .checkmark { + background: var(--theme-accent-cabbage-default); + border-color: transparent; } -} -:global(.light) .label:global(.checked) .checkmark { - background: theme('colors.raw.cabbage.60'); -} + &:global(.checked):hover:not(:global(.disabled)) .checkmark:before, + &:global(.indeterminate):hover:not(:global(.disabled)) .checkmark:before, + &:global(.checked) input:focus-visible ~ .checkmark:before, + &:global(.indeterminate) input:focus-visible ~ .checkmark:before { + background: theme('colors.overlay.quaternary.cabbage'); + } -:global(.light) .label:global(.checked):hover .checkmark, -:global(.light) .label:global(.checked):focus-within .checkmark { - background: theme('colors.raw.cabbage.80'); + /* Disabled — muted surface, no brand. */ + &:global(.disabled) .checkmark { + border-color: var(--theme-surface-disabled); + } + + &:global(.checked.disabled) .checkmark, + &:global(.indeterminate.disabled) .checkmark { + background: var(--theme-surface-disabled); + } } -@media (prefers-color-scheme: light) { - :global(.auto) .label:global(.checked) .checkmark { - background: theme('colors.raw.cabbage.60'); +/* Respect users who prefer reduced motion: keep the state changes, drop the + * movement (press squeeze) and instant-swap the glyph/halo transitions. */ +@media (prefers-reduced-motion: reduce) { + .checkmark, + .checkmark:before, + .dash, + .checkmark :global(.icon) { + transition: none; } - :global(.auto) .label:global(.checked):hover .checkmark, - :global(.auto) .label:global(.checked):focus-within .checkmark { - background: theme('colors.raw.cabbage.80'); + .label:active:not(:global(.disabled)) .checkmark { + transform: none; } } diff --git a/packages/shared/src/components/fields/Checkbox.spec.tsx b/packages/shared/src/components/fields/Checkbox.spec.tsx index 0dce1a08d13..6fcf1f4579a 100644 --- a/packages/shared/src/components/fields/Checkbox.spec.tsx +++ b/packages/shared/src/components/fields/Checkbox.spec.tsx @@ -35,3 +35,15 @@ it('should add checked class', async () => { // eslint-disable-next-line testing-library/no-node-access await waitFor(() => expect(el.parentElement).toHaveClass('checked')); }); + +it('should drive the native indeterminate property and aria-checked="mixed"', async () => { + renderComponent({ indeterminate: true }); + const el = (await screen.findByTestId('checkbox-input')) as HTMLInputElement; + await waitFor(() => expect(el.indeterminate).toBe(true)); + // eslint-disable-next-line testing-library/no-node-access + expect(el.parentElement).toHaveClass('indeterminate'); + // The decorative checkmark box mirrors the mixed state for assistive tech. + // eslint-disable-next-line testing-library/no-node-access + const box = el.parentElement?.querySelector('[role="checkbox"]'); + expect(box).toHaveAttribute('aria-checked', 'mixed'); +}); diff --git a/packages/shared/src/components/fields/Checkbox.tsx b/packages/shared/src/components/fields/Checkbox.tsx index 947cae7f16a..93bebc910a1 100644 --- a/packages/shared/src/components/fields/Checkbox.tsx +++ b/packages/shared/src/components/fields/Checkbox.tsx @@ -1,11 +1,18 @@ import type { ChangeEvent, - LegacyRef, + ForwardedRef, ReactElement, ReactNode, InputHTMLAttributes, } from 'react'; -import React, { forwardRef, useEffect, useState, useId } from 'react'; +import React, { + forwardRef, + useCallback, + useEffect, + useRef, + useState, + useId, +} from 'react'; import classNames from 'classnames'; import { VIcon } from '../icons'; import styles from './Checkbox.module.css'; @@ -13,6 +20,11 @@ import styles from './Checkbox.module.css'; export interface CheckboxProps extends InputHTMLAttributes { name: string; checked?: boolean; + /** + * Tri-state "mixed" checkbox (e.g. a parent of partially-selected children). + * Renders a dash instead of the check and exposes `aria-checked="mixed"`. + */ + indeterminate?: boolean; id?: string; children?: ReactNode; className?: string; @@ -24,6 +36,7 @@ export const Checkbox = forwardRef(function Checkbox( { name, checked, + indeterminate = false, children, className, checkmarkClassName, @@ -33,16 +46,46 @@ export const Checkbox = forwardRef(function Checkbox( defaultChecked, ...props }: CheckboxProps, - ref: LegacyRef, + ref: ForwardedRef, ): ReactElement { const [actualChecked, setActualChecked] = useState(checked ?? defaultChecked); const checkId = useId(); const inputId = id.concat(checkId); + const innerRef = useRef(null); + + // Merge the forwarded ref with our own and drive the native `indeterminate` + // property here (it has no HTML attribute, and setting it from the ref + // callback applies it at commit time whenever the node attaches or + // `indeterminate` changes — more reliable than a post-paint effect). + const setRefs = useCallback( + (node: HTMLInputElement | null) => { + innerRef.current = node; + if (node) { + // eslint-disable-next-line no-param-reassign + node.indeterminate = indeterminate; + } + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + // eslint-disable-next-line no-param-reassign + ref.current = node; + } + }, + [ref, indeterminate], + ); useEffect(() => { setActualChecked(checked ?? defaultChecked); }, [checked, defaultChecked]); + // Keep the native property in sync across re-renders that don't re-attach the + // ref (e.g. when the checked state changes via user interaction). + useEffect(() => { + if (innerRef.current) { + innerRef.current.indeterminate = indeterminate; + } + }, [indeterminate, actualChecked]); + const onChange = (event: ChangeEvent): void => { setActualChecked(event.target.checked); onToggleCallback?.(event.target.checked); @@ -51,11 +94,11 @@ export const Checkbox = forwardRef(function Checkbox( return (