diff --git a/packages/propel/src/components/checkbox-field/checkbox-field.stories.tsx b/packages/propel/src/components/checkbox-field/checkbox-field.stories.tsx index d0c6a427..3e9c8881 100644 --- a/packages/propel/src/components/checkbox-field/checkbox-field.stories.tsx +++ b/packages/propel/src/components/checkbox-field/checkbox-field.stories.tsx @@ -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, @@ -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); }, }; diff --git a/packages/propel/src/components/checkbox-field/checkbox-field.tsx b/packages/propel/src/components/checkbox-field/checkbox-field.tsx index 3e5c11e4..522a4912 100644 --- a/packages/propel/src/components/checkbox-field/checkbox-field.tsx +++ b/packages/propel/src/components/checkbox-field/checkbox-field.tsx @@ -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; + +export type CheckboxFieldProps = Omit & { /** Helper text shown below the control. Replaced by `error` when an error is set. */ hint?: React.ReactNode; /** Error text shown below the control. */ @@ -42,7 +39,7 @@ export function CheckboxField({ - + {label} diff --git a/packages/propel/src/components/checkbox-group-field/checkbox-group-field-option.tsx b/packages/propel/src/components/checkbox-group-field/checkbox-group-field-option.tsx index 4096edec..0389ef67 100644 --- a/packages/propel/src/components/checkbox-group-field/checkbox-group-field-option.tsx +++ b/packages/propel/src/components/checkbox-group-field/checkbox-group-field-option.tsx @@ -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; + +export type CheckboxGroupFieldOptionProps = Omit & { /** Visible option label. */ label: string; /** Optional supporting text announced as the checkbox description. */ @@ -33,7 +30,7 @@ export function CheckboxGroupFieldOption({ return ( - + {label} diff --git a/packages/propel/src/components/checkbox-group-field/checkbox-group-field.stories.tsx b/packages/propel/src/components/checkbox-group-field/checkbox-group-field.stories.tsx index fb65e1ee..5990ae7a 100644 --- a/packages/propel/src/components/checkbox-group-field/checkbox-group-field.stories.tsx +++ b/packages/propel/src/components/checkbox-group-field/checkbox-group-field.stories.tsx @@ -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); } } }, diff --git a/packages/propel/src/components/checkbox/checkbox.stories.tsx b/packages/propel/src/components/checkbox/checkbox.stories.tsx index 3fb3b202..c931d267 100644 --- a/packages/propel/src/components/checkbox/checkbox.stories.tsx +++ b/packages/propel/src/components/checkbox/checkbox.stories.tsx @@ -79,6 +79,7 @@ export const States: Story = { + ), }; @@ -114,6 +115,22 @@ export const WithLabel: Story = { render: () => , }; +/** + * 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. @@ -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, @@ -184,9 +201,9 @@ 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"); @@ -194,12 +211,10 @@ export const InvalidInteraction: Story = { 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); }, }; @@ -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: () => ( + } + 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 diff --git a/packages/propel/src/components/checkbox/checkbox.tsx b/packages/propel/src/components/checkbox/checkbox.tsx index c0492f01..92d2c48b 100644 --- a/packages/propel/src/components/checkbox/checkbox.tsx +++ b/packages/propel/src/components/checkbox/checkbox.tsx @@ -17,11 +17,15 @@ export type CheckboxProps = Omit`. 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; }; @@ -31,7 +35,7 @@ export type CheckboxProps = Omit, "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"> = { diff --git a/packages/propel/src/elements/checkbox/checkbox.stories.tsx b/packages/propel/src/elements/checkbox/checkbox.stories.tsx index 72062568..99be2c91 100644 --- a/packages/propel/src/elements/checkbox/checkbox.stories.tsx +++ b/packages/propel/src/elements/checkbox/checkbox.stories.tsx @@ -52,20 +52,35 @@ export const Default: Story = { /** * Every visual state, pinned statically. Resting is the bare box (Base UI mounts no indicator while * unchecked); `data-checked` fills the accent and shows the check; `data-indeterminate` hides the - * check and reveals the dash; focus-visible (forced via pseudo-states) draws the accent ring; - * `data-disabled` dims the border, fill, and glyph; `data-invalid` is the error look — a STATE, not - * a prop: inside an invalid `Field.Root` Base UI sets it on the box (any host can set it directly, - * as here) to recolor the resting border to danger. Once checked, the invalid box keeps the same - * accent fill as every other checked state. + * check and reveals the dash; hover (forced via pseudo-states) paints the box-level treatments — + * `transparent-hover` wash while unchecked, darker `accent-primary-hover` while checked or + * indeterminate; focus-visible (also forced) draws the accent ring; `data-disabled` dims the + * border, fill, and glyph; `data-invalid` is the error look — a STATE, not a prop: inside an + * invalid `Field.Root` Base UI sets it on the box (any host can set it directly, as here) to + * recolor the resting border to danger. Once checked, the invalid box keeps the same accent fill as + * every other checked state. */ export const States: Story = { parameters: { controls: { disable: true }, - pseudo: { focusVisible: ["#elements-checkbox-focus-visible"] }, + pseudo: { + hover: [ + "#elements-checkbox-hover-unchecked", + "#elements-checkbox-hover-checked", + "#elements-checkbox-hover-indeterminate", + ], + focusVisible: ["#elements-checkbox-focus-visible"], + }, }, render: () => (
+ @@ -74,6 +89,20 @@ export const States: Story = { + + + + + + + + + + + + + + + +