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
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { CheckboxField, type FieldMagnitude } from "./index";

const MAGNITUDES: FieldMagnitude[] = ["md", "lg", "xl"];

// A single checkbox laid out as a field row (Field + CheckboxFieldControl + label/helper).
// A single checkbox laid out as a field row (Field + bare Checkbox + label/helper).
const meta = {
title: "Components/CheckboxField",
component: CheckboxField,
Expand Down Expand Up @@ -97,10 +97,10 @@ export const InvalidInteraction: Story = {
await expect(resting).not.toHaveAttribute("data-invalid");
// The invalid field propagates `data-invalid` onto the box (Base UI Field -> Checkbox.Root).
await expect(invalid).toHaveAttribute("data-invalid");
// ...and the danger border actually renders: its color differs from the resting box's border.
await expect(getComputedStyle(invalid).borderColor).not.toBe(
getComputedStyle(resting).borderColor,
);
// ...and the danger stroke actually renders: the invalid box's inset box-shadow (danger stroke)
// differs from the resting box's (tertiary stroke). The stroke is a box-shadow, not a
// border-color — the border is a transparent 1px gutter.
await expect(getComputedStyle(invalid).boxShadow).not.toBe(getComputedStyle(resting).boxShadow);
},
};

Expand Down
15 changes: 6 additions & 9 deletions packages/propel/src/components/checkbox-field/checkbox-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,16 @@ import type * as React from "react";

import { FieldItemControlGroup } from "../../elements/field/field-item-control-group";
import type { FieldMagnitude } from "../../elements/field/variants";
import {
CheckboxFieldControl,
type CheckboxFieldControlProps,
} from "../../internal/checkbox-field-control";
import { Checkbox, type CheckboxProps } from "../checkbox";
import { Field, FieldItem, FieldItemContent } from "../field";
import { FieldHelperText } from "../field/field-helper-text";

export type { FieldMagnitude } from "../../elements/field/variants";

export type CheckboxFieldProps = Omit<
CheckboxFieldControlProps,
"aria-label" | "label" | "icon"
> & {
/** Bare checkbox control props for a field row — no chip `label` / `icon` (Field owns labeling). */
type CheckboxFieldControlProps = Omit<CheckboxProps, "children" | "label" | "icon">;

export type CheckboxFieldProps = Omit<CheckboxFieldControlProps, "aria-label"> & {
/** Helper text shown below the control. Replaced by `error` when an error is set. */
hint?: React.ReactNode;
/** Error text shown below the control. */
Expand Down Expand Up @@ -42,7 +39,7 @@ export function CheckboxField({
<Field name={name} disabled={disabled} invalid={error != null || undefined}>
<FieldItem disabled={disabled}>
<FieldItemControlGroup>
<CheckboxFieldControl disabled={disabled} {...controlProps} />
<Checkbox disabled={disabled} {...controlProps} />
</FieldItemControlGroup>
<FieldItemContent magnitude={magnitude} description={description}>
{label}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@ import type * as React from "react";

import { FieldItemControlGroup } from "../../elements/field/field-item-control-group";
import type { FieldMagnitude } from "../../elements/field/variants";
import {
CheckboxFieldControl,
type CheckboxFieldControlProps,
} from "../../internal/checkbox-field-control";
import { useFieldOptionMagnitude } from "../../internal/field-option-magnitude";
import { Checkbox, type CheckboxProps } from "../checkbox";
import { FieldItem, FieldItemContent } from "../field";

export type CheckboxGroupFieldOptionProps = Omit<
CheckboxFieldControlProps,
"aria-label" | "label" | "icon"
> & {
/** Bare checkbox control props for a field row — no chip `label` / `icon` (Field owns labeling). */
type CheckboxFieldControlProps = Omit<CheckboxProps, "children" | "label" | "icon">;

export type CheckboxGroupFieldOptionProps = Omit<CheckboxFieldControlProps, "aria-label"> & {
/** Visible option label. */
label: string;
/** Optional supporting text announced as the checkbox description. */
Expand All @@ -33,7 +30,7 @@ export function CheckboxGroupFieldOption({
return (
<FieldItem disabled={props.disabled}>
<FieldItemControlGroup>
<CheckboxFieldControl {...props} />
<Checkbox {...props} />
</FieldItemControlGroup>
<FieldItemContent magnitude={magnitude} description={description}>
{label}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,18 @@ export const InvalidInteraction: Story = {
...Invalid,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas }) => {
const dangerBorder = getComputedStyle(document.documentElement)
const dangerStroke = getComputedStyle(document.documentElement)
.getPropertyValue("--border-danger-strong")
.trim();
for (const box of canvas.getAllByRole("checkbox")) {
await expect(box).toHaveAttribute("data-invalid");
const borderColor = getComputedStyle(box).borderColor;
// The danger stroke is an inset box-shadow, not a border-color (the border is a transparent
// gutter). Checked boxes drop the stroke (`data-checked:shadow-none`); unchecked keep it.
const boxShadow = getComputedStyle(box).boxShadow;
if (box.getAttribute("aria-checked") === "true") {
await expect(borderColor).not.toBe(dangerBorder);
await expect(boxShadow).not.toContain(dangerStroke);
} else {
await expect(borderColor).toBe(dangerBorder);
await expect(boxShadow).toContain(dangerStroke);
}
}
},
Expand Down
76 changes: 63 additions & 13 deletions packages/propel/src/components/checkbox/checkbox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export const States: Story = {
<Checkbox label="Indeterminate" indeterminate />
<Checkbox label="Disabled" disabled />
<Checkbox label="Disabled checked" disabled defaultChecked />
<Checkbox label="Disabled indeterminate" disabled indeterminate />
</div>
),
};
Expand Down Expand Up @@ -114,6 +115,22 @@ export const WithLabel: Story = {
render: () => <Checkbox label="Send me product updates" defaultChecked />,
};

/**
* Interaction test: clicking the label text (not the box) toggles via the ready-made's
* `htmlFor`/`id` association. Tagged out of the sidebar/docs/manifest while still running under the
* default `test` tag.
*/
export const LabelClickToggles: Story = {
...WithLabel,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas, userEvent }) => {
const box = canvas.getByRole("checkbox");
await expect(box).toHaveAttribute("aria-checked", "true");
await userEvent.click(canvas.getByText("Send me product updates"));
await expect(box).toHaveAttribute("aria-checked", "false");
},
};

/**
* An optional `icon` sits between the box and the label, matching the Figma "checkbox with label"
* icon slot.
Expand Down Expand Up @@ -169,10 +186,10 @@ export const Invalid: Story = {
};

/**
* Interaction test: the invalid `Field` propagates `data-invalid` and the danger border on the
* RESTING box only — once checked, the danger border clears back to transparent (the accent fill
* alone communicates the value; a required-and-now-satisfied checkbox shouldn't still ring red).
* Tagged out of the sidebar/docs/manifest while still running under the default `test` tag.
* Interaction test: the invalid `Field` propagates `data-invalid` and the danger inset stroke on
* the RESTING box only — once checked, the stroke clears (`shadow-none`) so the accent fill alone
* communicates the value; a required-and-now-satisfied checkbox shouldn't still ring red. Tagged
* out of the sidebar/docs/manifest while still running under the default `test` tag.
*/
export const InvalidInteraction: Story = {
...Invalid,
Expand All @@ -184,22 +201,20 @@ export const InvalidInteraction: Story = {
// The invalid `Field` propagates `data-invalid` onto the box (Base UI Field -> Checkbox.Root).
await expect(unchecked).toHaveAttribute("data-invalid");
await expect(unchecked).toHaveAttribute("aria-checked", "false");
// ...and the danger border actually renders: its border color differs from the resting box.
await expect(getComputedStyle(unchecked).borderColor).not.toBe(
getComputedStyle(resting).borderColor,
// ...and the danger inset stroke actually renders: its box-shadow differs from the resting box.
await expect(getComputedStyle(unchecked).boxShadow).not.toBe(
getComputedStyle(resting).boxShadow,
);
// Checked invalid box: accent-blue fill, like every other state...
await expect(checked).toHaveAttribute("aria-checked", "true");
await expect(checked).toHaveAttribute("data-invalid");
await expect(getComputedStyle(checked).backgroundColor).not.toBe(
getComputedStyle(resting).backgroundColor,
);
// ...and, despite still being `data-invalid`, the danger border does NOT persist through the
// checked fill: `data-checked:border-transparent` takes over, same as a checked box anywhere
// else — it must NOT still show the unchecked invalid box's red border.
await expect(getComputedStyle(checked).borderColor).not.toBe(
getComputedStyle(unchecked).borderColor,
);
// ...and, despite still being `data-invalid`, the danger stroke does NOT persist through the
// checked fill: `data-checked:shadow-none` takes over — it must NOT still show the unchecked
// invalid box's red inset stroke. (Ring tokens may leave transparent 0px layers.)
await expect(getComputedStyle(checked).boxShadow.includes("1px inset")).toBe(false);
},
};

Expand Down Expand Up @@ -356,6 +371,41 @@ export const BoxDoesNotShiftOnToggle: Story = {
},
};

/**
* Same baseline-shift trap as `BoxDoesNotShiftOnToggle`, but on the labeled row: the
* `CheckboxLabel` is itself `inline-flex`, so without `align-top` its baseline moves when the
* nested box mounts the check — nudging the whole chip (box + optional icon + text) ~2px. Assert
* the label row's geometry is identical across unchecked / checked.
*/
export const LabeledRowDoesNotShiftOnToggle: Story = {
...Interaction,
tags: ["!dev", "!autodocs", "!manifest"],
parameters: { controls: { disable: true } },
render: () => (
<Checkbox
icon={<Icon icon={Repeat} tint="secondary" magnitude="sm" />}
label="Sync automatically"
/>
),
play: async ({ canvas }) => {
const box = canvas.getByRole("checkbox");
const row = box.closest("label");
if (!row) throw new Error("expected the labeled checkbox's label row");

await expect(box).toHaveAttribute("aria-checked", "false");
const unchecked = row.getBoundingClientRect();

await userEvent.click(box);
await expect(box).toHaveAttribute("aria-checked", "true");
const checked = row.getBoundingClientRect();

await expect(checked.x).toBe(unchecked.x);
await expect(checked.y).toBe(unchecked.y);
await expect(checked.width).toBe(unchecked.width);
await expect(checked.height).toBe(unchecked.height);
},
};

/**
* Pure interaction test: a disabled checkbox does not toggle when clicked. Tagged
* `!dev`/`!autodocs`/`!manifest` so it stays out of the sidebar, docs, and the AI/MCP manifest, but
Expand Down
10 changes: 7 additions & 3 deletions packages/propel/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,15 @@ export type CheckboxProps = Omit<BaseCheckbox.Root.Props, "className" | "style"
* or `aria-labelledby`.
*/
label?: string;
/** Width of the clickable label row when `label` is present. */
/**
* Width of the clickable label row when `label` is present. Defaults to `"hug"` (ready-made
* concern — the elements `CheckboxLabel` requires `sizing` explicitly).
*/
sizing?: CheckboxLabelSizing;
/**
* Element shown between the box and the label (inline-start), e.g. `<Icon icon={User}
* tint="secondary" magnitude="sm" />`. Only rendered when `label` is present.
* tint="secondary" magnitude="sm" />`. Only rendered when `label` is present. Prefer the shared
* `Icon` (a direct `aria-hidden` span) so the label's disabled tint override can recolor it.
*/
icon?: React.ReactNode;
};
Expand All @@ -31,7 +35,7 @@ export type CheckboxProps = Omit<BaseCheckbox.Root.Props, "className" | "style"
* check and indeterminate indicators, and optionally wraps the row in a clickable `CheckboxLabel`
* with an icon slot.
*/
export function Checkbox({ label, sizing, icon, id, ...props }: CheckboxProps) {
export function Checkbox({ label, sizing = "hug", icon, id, ...props }: CheckboxProps) {
// Generate a stable id so an explicit `label` can be associated with the box.
const generatedId = React.useId();
const checkboxId = id ?? generatedId;
Expand Down
6 changes: 4 additions & 2 deletions packages/propel/src/elements/checkbox/checkbox-label.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ export type CheckboxLabelProps = Omit<useRender.ComponentProps<"label">, "classN
CheckboxLabelVariantProps;

/**
* The clickable row chip that wraps a `Checkbox` box with an optional `CheckboxIcon` and the label
* The clickable row chip that wraps a `Checkbox` box with an optional leading icon and the label
* text, matching the Figma "Checkbox with label" component. Associate it with the box via `htmlFor`
* so clicking anywhere in the row toggles the box. The row reads its disabled look off the wrapped
* `Checkbox` (which carries `data-disabled`) via `:has()`, so it takes no `disabled` prop.
* `Checkbox` (which carries `data-disabled`) via `:has()`, so it takes no `disabled` prop. Pass the
* icon as a direct-child `aria-hidden` span (e.g. the shared `Icon`) so the disabled tint override
* applies.
*/
export function CheckboxLabel({ sizing, render, ...props }: CheckboxLabelProps) {
const defaultProps: useRender.ElementProps<"label"> = {
Expand Down
Loading