Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/checkbox-indeterminate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"paris": minor
---

Add an indeterminate state to `Checkbox`.

- `checked`, `value`, and `defaultChecked` accept `boolean | 'indeterminate'`; `onChange` still emits a boolean, and toggling from indeterminate emits `true`.
- The `default` and `panel` kinds render a dash glyph and the `surface` kind renders the new `Minus` icon; `kind="switch"` renders indeterminate as unchecked.
- Exports the `CheckedState` type from `paris/checkbox` and the `Minus` icon from `paris/icon`.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ src/stories/<componentname>/
- **Input** - Text input field
- **TextArea** - Multi-line text input
- **Select** - Dropdown/listbox/radio/segmented selection
- **Checkbox** - Checkbox with switch variant; `value` is a boolean alias for `checked` and rest props land on the control, so a form field object can be spread directly
- **Checkbox** - Checkbox with switch variant; `checked` (and its alias `value`) accepts `boolean | 'indeterminate'` while `onChange` always emits a boolean (`true` from indeterminate; switches render indeterminate as unchecked); rest props land on the control, so a form field object can be spread directly
- **Combobox** - Autocomplete input
- **PhoneInput** - Country select + phone number input; `value`/`onChange` are E.164, display formats to national format on blur
- **AccordionSelect** - Card header that expands to reveal selectable options
Expand Down
2 changes: 1 addition & 1 deletion public/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import { Input } from 'paris/input';
- Input - Text input (text, email, password, etc.)
- TextArea - Multi-line text
- Select - Dropdown/listbox/radio/segmented selection
- Checkbox - Checkbox with switch variant; `value` is a boolean alias for `checked` and rest props land on the control, so a form field object can be spread directly
- Checkbox - Checkbox with switch variant; `checked`/`value` accept `boolean | 'indeterminate'`, `onChange` emits a boolean; rest props land on the control, so a form field object can be spread directly
- Combobox - Autocomplete input
- PhoneInput - Country select + phone input; E.164 value, localized display formatting on blur
- AccordionSelect - Card header that expands to reveal selectable options
Expand Down
34 changes: 34 additions & 0 deletions src/stories/checkbox/Checkbox.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,40 @@ export const HideLabel: Story = {
},
};

/** A "select all" Checkbox that shows the mixed state while only some of its children are checked. */
export const Indeterminate: Story = {
args: {
children: 'Select all',
},
render: function Render(args) {
const [selected, setSelected] = useState([true, false, false]);
const allChecked = selected.every(Boolean);
const noneChecked = !selected.some(Boolean);
return createElement(
'div',
{ style: { display: 'flex', flexDirection: 'column', gap: 8 } },
createElement(Checkbox, {
...args,
checked: allChecked ? true : noneChecked ? false : 'indeterminate',
onChange: (checked: boolean) => setSelected(selected.map(() => checked)),
}),
...selected.map((isChecked, index) =>
createElement(
Checkbox,
{
key: index,
kind: args.kind,
checked: isChecked,
onChange: (checked: boolean) =>
setSelected(selected.map((prev, i) => (i === index ? checked : prev))),
},
`Option ${index + 1}`,
),
),
);
},
};

/** A `react-hook-form` field object spread straight onto the Checkbox, with no `checked` adapter. */
export const FormField: Story = {
args: {
Expand Down
65 changes: 65 additions & 0 deletions src/stories/checkbox/Checkbox.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,71 @@ describe('Checkbox', () => {
});
});

describe('indeterminate', () => {
it.each(['default', 'surface', 'panel'] as const)('renders a mixed state for kind="%s"', (kind) => {
render(
<Checkbox kind={kind} checked="indeterminate">
Select all
</Checkbox>,
);
const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveAttribute('aria-checked', 'mixed');
expect(checkbox).toHaveAttribute('data-state', 'indeterminate');
});

it('emits true when an indeterminate checkbox is clicked', async () => {
const handleChange = vi.fn();
const { user } = render(
<Checkbox checked="indeterminate" onChange={handleChange}>
Select all
</Checkbox>,
);

await user.click(screen.getByRole('checkbox'));
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith(true);
});

it('accepts indeterminate through the value alias', () => {
render(<Checkbox value="indeterminate">Select all</Checkbox>);
expect(screen.getByRole('checkbox')).toHaveAttribute('aria-checked', 'mixed');
});

it('becomes checked after clicking an uncontrolled indeterminate checkbox', async () => {
const handleChange = vi.fn();
const { user } = render(
<Checkbox defaultChecked="indeterminate" onChange={handleChange}>
Select all
</Checkbox>,
);

const checkbox = screen.getByRole('checkbox');
expect(checkbox).toHaveAttribute('aria-checked', 'mixed');
await user.click(checkbox);
expect(checkbox).toBeChecked();
expect(handleChange).toHaveBeenCalledWith(true);
});

it('does not apply the checked class to the container', () => {
const { container } = render(<Checkbox checked="indeterminate">Select all</Checkbox>);
expect(container.querySelector('label')).not.toHaveClass('checked');
});

it('renders a switch as unchecked', async () => {
const handleChange = vi.fn();
const { user } = render(
<Checkbox kind="switch" checked="indeterminate" onChange={handleChange}>
Toggle
</Checkbox>,
);

const switchEl = screen.getByRole('switch');
expect(switchEl).not.toBeChecked();
await user.click(switchEl);
expect(handleChange).toHaveBeenCalledWith(true);
});
});

// ─── Accessibility ───────────────────────────────────────────────

describe('accessibility', () => {
Expand Down
44 changes: 26 additions & 18 deletions src/stories/checkbox/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,26 @@ import { clsx } from 'clsx';
import type { ReactNode } from 'react';
import { forwardRef, useId } from 'react';
import { useControllableState } from '../../helpers/useControllableState';
import { Check, Icon } from '../icon';
import { Check, Icon, Minus } from '../icon';
import { TextWhenString, VisuallyHidden } from '../utility';
import styles from './Checkbox.module.scss';

export type CheckedState = boolean | 'indeterminate';

type CheckboxOwnProps = {
/** The visual style of the Checkbox. `default` is a standard checkbox with a label next to it, `surface` is a clickable card that displays a check when selected, `panel` is a clickable card with the checkbox aligned right, `switch` is a switch toggle. */
kind?: 'default' | 'surface' | 'panel' | 'switch';
/** The checked state for controlled mode. Takes precedence over `value`. */
checked?: boolean;
/**
* The checked state for controlled mode. Takes precedence over `value`. `'indeterminate'` renders
* a mixed state (e.g. a "select all" over a partial selection); toggling it emits `true`.
* `kind="switch"` has no mixed state and renders `'indeterminate'` as unchecked.
*/
checked?: CheckedState;
/**
* An alias for `checked`, so a form library's field object can be spread directly
* (e.g. `<Checkbox {...field} />` with `react-hook-form`). Ignored when `checked` is set.
*/
value?: boolean;
value?: CheckedState;
/** Called with the new checked state whenever the Checkbox is toggled. */
onChange?: (checked: boolean) => void;
disabled?: boolean;
Expand All @@ -35,7 +41,7 @@ type CheckboxOwnProps = {
*/
hideLabel?: boolean;
/** The initial checked state for uncontrolled mode. If `checked` is provided, this is ignored. */
defaultChecked?: boolean;
defaultChecked?: CheckedState;
/** The contents of the Checkbox. */
children?: ReactNode | ReactNode[];
};
Expand Down Expand Up @@ -76,21 +82,18 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
) => {
const generatedID = useId();
const inputID = id ?? generatedID;
const [resolvedChecked, setResolvedChecked] = useControllableState<boolean>({
const [resolvedChecked, setResolvedChecked] = useControllableState<CheckedState>({
value: checked ?? value,
defaultValue: defaultChecked,
onChange,
onChange: (next) => onChange?.(next === true),
});
const isChecked = resolvedChecked === true;
const isIndeterminate = resolvedChecked === 'indeterminate';
return (
<label
htmlFor={inputID}
style={style}
className={clsx(
styles.container,
disabled && styles.disabled,
className,
resolvedChecked && styles.checked,
)}
className={clsx(styles.container, disabled && styles.disabled, className, isChecked && styles.checked)}
>
{(kind === 'default' || kind === 'surface' || kind === 'panel') && (
<RadixCheckbox.Root
Expand All @@ -99,7 +102,7 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
id={inputID}
className={clsx(styles.root, styles[kind])}
checked={resolvedChecked}
onCheckedChange={(v) => setResolvedChecked(v === 'indeterminate' ? true : v)}
onCheckedChange={(v) => setResolvedChecked(v === true)}
disabled={disabled}
data-disabled={disabled || undefined}
data-status={status}
Expand All @@ -121,13 +124,18 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
<path
className={styles.checkSvg}
data-disabled={disabled || undefined}
d="M0.333374 0.333252V13.6666H13.6667V0.333252H0.333374ZM6.00004 10.3999L2.26672 6.66658L3.66671 5.26658L5.93339 7.53325L10.2 3.26658L11.6001 4.66659L6.00004 10.3999Z"
fillRule="evenodd"
d={
isIndeterminate
? 'M0.333374 0.333252V13.6666H13.6667V0.333252H0.333374ZM3 6H11V8H3V6Z'
: 'M0.333374 0.333252V13.6666H13.6667V0.333252H0.333374ZM6.00004 10.3999L2.26672 6.66658L3.66671 5.26658L5.93339 7.53325L10.2 3.26658L11.6001 4.66659L6.00004 10.3999Z'
}
/>
</svg>
)}
{kind === 'surface' && (
<Icon
icon={Check}
icon={isIndeterminate ? Minus : Check}
size={12.8}
data-disabled={disabled || undefined}
className={styles.checkIcon}
Expand All @@ -140,7 +148,7 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
<Switch
{...props}
ref={ref}
checked={resolvedChecked}
checked={isChecked}
onChange={setResolvedChecked}
className={styles.switchContainer}
disabled={disabled}
Expand All @@ -149,7 +157,7 @@ export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
id={inputID}
aria-details={typeof children === 'string' ? children : undefined}
>
<span aria-hidden="true" className={clsx(styles.knob, resolvedChecked && styles.knobChecked)} />
<span aria-hidden="true" className={clsx(styles.knob, isChecked && styles.knobChecked)} />
</Switch>
)}
{(kind === 'default' || kind === 'switch') && !hideLabel && (
Expand Down
1 change: 1 addition & 0 deletions src/stories/icon/Icon.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,4 @@ export const NotificationDot: Story = getArgs(Icons.NotificationDot);
export const Spinner: Story = getArgs(Icons.Spinner);
export const Check: Story = getArgs(Icons.Check);
export const ArrowRight: Story = getArgs(Icons.ArrowRight);
export const Minus: Story = getArgs(Icons.Minus);
11 changes: 11 additions & 0 deletions src/stories/icon/Minus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { memo } from 'react';
import type { IconDefinition } from './Icon';

export const Minus: IconDefinition = memo(({ size }) => (
<svg width={size} height={size} viewBox="0 0 448 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M432 256c0 17.7-14.3 32-32 32L48 288c-17.7 0-32-14.3-32-32s14.3-32 32-32l352 0c17.7 0 32 14.3 32 32z"
fill="currentColor"
/>
</svg>
));
1 change: 1 addition & 0 deletions src/stories/icon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export { Close } from './Close';
export { Ellipsis } from './Ellipsis';
export * from './Icon';
export { Info } from './Info';
export { Minus } from './Minus';
export { NotificationDot } from './NotificationDot';
export { Spinner } from './Spinner';
Loading