Skip to content
Open
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
160 changes: 100 additions & 60 deletions packages/shared/src/components/fields/Checkbox.module.css
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}
}
12 changes: 12 additions & 0 deletions packages/shared/src/components/fields/Checkbox.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
64 changes: 54 additions & 10 deletions packages/shared/src/components/fields/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
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';

export interface CheckboxProps extends InputHTMLAttributes<HTMLInputElement> {
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;
Expand All @@ -24,6 +36,7 @@ export const Checkbox = forwardRef(function Checkbox(
{
name,
checked,
indeterminate = false,
children,
className,
checkmarkClassName,
Expand All @@ -33,16 +46,46 @@ export const Checkbox = forwardRef(function Checkbox(
defaultChecked,
...props
}: CheckboxProps,
ref: LegacyRef<HTMLInputElement>,
ref: ForwardedRef<HTMLInputElement>,
): ReactElement {
const [actualChecked, setActualChecked] = useState(checked ?? defaultChecked);
const checkId = useId();
const inputId = id.concat(checkId);
const innerRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>): void => {
setActualChecked(event.target.checked);
onToggleCallback?.(event.target.checked);
Expand All @@ -51,11 +94,11 @@ export const Checkbox = forwardRef(function Checkbox(
return (
<label
className={classNames(
'relative z-1 inline-flex select-none items-center p-1 pr-3 text-text-tertiary typo-footnote',
'relative z-1 inline-flex select-none items-center p-1 pr-3 font-medium text-text-secondary antialiased typo-footnote',
!disabled && 'cursor-pointer',
styles.label,
className,
{ checked: actualChecked, disabled },
{ checked: actualChecked, indeterminate, disabled },
)}
style={{ transition: 'color 0.1s linear' }}
htmlFor={inputId}
Expand All @@ -70,26 +113,27 @@ export const Checkbox = forwardRef(function Checkbox(
id={inputId}
name={name}
onChange={onChange}
ref={ref}
type="checkbox"
{...props}
ref={setRefs}
/>
<div
aria-checked={checked}
aria-checked={indeterminate ? 'mixed' : actualChecked}
aria-labelledby={`label-${checkId}`}
className={classNames(
'relative z-1 mr-3 flex h-5 w-5 items-center justify-center rounded-6 border-2 border-border-subtlest-primary',
'relative z-1 mr-3 flex h-5 w-5 items-center justify-center rounded-6 border-2',
styles.checkmark,
checkmarkClassName,
)}
role="checkbox"
>
<VIcon
aria-hidden
className="icon h-full w-full text-text-primary opacity-0"
secondary
className={classNames('icon h-full w-full scale-110 opacity-0')}
role="presentation"
style={{ transition: 'opacity 0.1s linear' }}
/>
<span aria-hidden className={styles.dash} />
</div>
<span className="min-w-0 flex-1" id={`label-span-${checkId}`}>
{children}
Expand Down
Loading
Loading