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: 8 additions & 1 deletion packages/propel/src/components/checkbox/checkbox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ import { Repeat } from "lucide-react";
import * as React from "react";
import { expect, userEvent } from "storybook/test";

import { Checkbox } from "./index";
import {
Checkbox,
CheckboxGlyph,
CheckboxIndicator,
CheckboxInlineStartNode,
CheckboxLabel,
} from "./index";

const meta = {
title: "Components/Checkbox",
component: Checkbox,
subcomponents: { CheckboxLabel, CheckboxInlineStartNode, CheckboxIndicator, CheckboxGlyph },
args: {
tone: "neutral",
"aria-label": "Example",
Expand Down
27 changes: 6 additions & 21 deletions packages/propel/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { cx } from "class-variance-authority";
import * as React from "react";

import { nodeSlotClass } from "../../internal/node-slot";
import {
Checkbox as CheckboxRoot,
CheckboxGlyph,
CheckboxIndicator,
CheckboxInlineStartNode,
CheckboxLabel,
type CheckboxProps as CheckboxRootProps,
} from "../../ui/checkbox";

Expand All @@ -28,7 +28,7 @@ export type CheckboxProps = CheckboxRootProps & {

/**
* The ready-made checkbox: composes the atomic `Checkbox` box with its `CheckboxIndicator`/glyph,
* and optionally wraps the row in a clickable `label` with an icon slot.
* and optionally wraps the row in a clickable `CheckboxLabel` with an icon slot.
*/
export function Checkbox({ tone, label, inlineStartNode, id, ...props }: CheckboxProps) {
// Generate a stable id so an explicit `label` can be associated with the box.
Expand All @@ -46,27 +46,12 @@ export function Checkbox({ tone, label, inlineStartNode, id, ...props }: Checkbo
if (label == null) return box;

return (
<label
// Figma "Checkbox with label" (node 1274:109) wraps the row in a clickable
// chip: `px-2 py-1` (8px/4px) padding and a `rounded-sm` corner, with a
// transparent-layer hover background (hover state 1276:15 →
// `bg-layer-transparent-hover`). The standalone box owns its own styling.
className={cx(
"inline-flex items-center gap-2 rounded-sm px-2 py-1 text-13 text-secondary transition-colors",
props.disabled ? "cursor-not-allowed" : "cursor-pointer hover:bg-layer-transparent-hover",
)}
htmlFor={checkboxId}
>
<CheckboxLabel disabled={props.disabled ?? false} htmlFor={checkboxId}>
{box}
{inlineStartNode ? (
<span
aria-hidden
className={cx(nodeSlotClass, "text-icon-secondary [--node-size:0.875rem]")}
>
{inlineStartNode}
</span>
<CheckboxInlineStartNode>{inlineStartNode}</CheckboxInlineStartNode>
) : null}
{label}
</label>
</CheckboxLabel>
);
}
4 changes: 4 additions & 0 deletions packages/propel/src/components/checkbox/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ export {
type CheckboxIndicatorProps,
CheckboxIndicator,
CheckboxGlyph,
CheckboxInlineStartNode,
type CheckboxInlineStartNodeProps,
CheckboxLabel,
type CheckboxLabelProps,
CheckboxVisual,
type CheckboxVisualProps,
} from "../../ui/checkbox";
11 changes: 6 additions & 5 deletions packages/propel/src/ui/checkbox/checkbox-glyph.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Check, Minus } from "lucide-react";

/**
* The mark drawn inside a checked / indeterminate box: a checkmark, or a horizontal dash when
* `indeterminate`. Decorative (the Root carries the a11y state). Renders a single bare icon — its
* size comes from the enclosing `CheckboxIndicator` slot (`--node-size`), never baked in here.
*/
export function CheckboxGlyph({ indeterminate }: { indeterminate?: boolean }) {
return indeterminate ? (
<Minus aria-hidden className="size-3" />
) : (
<Check aria-hidden className="size-3" />
);
return indeterminate ? <Minus aria-hidden /> : <Check aria-hidden />;
}
4 changes: 3 additions & 1 deletion packages/propel/src/ui/checkbox/checkbox-indicator.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Checkbox as BaseCheckbox } from "@base-ui/react/checkbox";

import { checkboxIndicatorVariants } from "./variants";

export type CheckboxIndicatorProps = Omit<BaseCheckbox.Indicator.Props, "className" | "style">;

/**
Expand All @@ -8,5 +10,5 @@ export type CheckboxIndicatorProps = Omit<BaseCheckbox.Indicator.Props, "classNa
* carries the a11y state. Children — typically a `CheckboxGlyph` — are passed through.
*/
export function CheckboxIndicator(props: CheckboxIndicatorProps) {
return <BaseCheckbox.Indicator className="flex items-center justify-center" {...props} />;
return <BaseCheckbox.Indicator className={checkboxIndicatorVariants()} {...props} />;
}
17 changes: 17 additions & 0 deletions packages/propel/src/ui/checkbox/checkbox-inline-start-node.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type * as React from "react";

import { checkboxInlineStartNodeVariants } from "./variants";

export type CheckboxInlineStartNodeProps = Omit<
React.ComponentPropsWithoutRef<"span">,
"className" | "style"
>;

/**
* The decorative icon slot between the box and the label text (Figma "checkbox with label" icon
* slot). Sizes its single child to `--node-size` (14px); pass a bare icon. Decorative — the box and
* label carry the accessible name — so it is `aria-hidden`.
*/
export function CheckboxInlineStartNode(props: CheckboxInlineStartNodeProps) {
return <span aria-hidden className={checkboxInlineStartNodeVariants()} {...props} />;
}
20 changes: 20 additions & 0 deletions packages/propel/src/ui/checkbox/checkbox-label.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type * as React from "react";

import { checkboxLabelVariants, type CheckboxLabelDisabled } from "./variants";

export type CheckboxLabelProps = Omit<
React.ComponentPropsWithoutRef<"label">,
"className" | "style"
> & {
/** Whether the row reads as disabled (drops the pointer cursor and hover background). */
disabled: CheckboxLabelDisabled;
};

/**
* The clickable row chip that wraps a `Checkbox` box with an optional `CheckboxInlineStartNode` 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.
*/
export function CheckboxLabel({ disabled, ...props }: CheckboxLabelProps) {
return <label className={checkboxLabelVariants({ disabled })} {...props} />;
}
8 changes: 6 additions & 2 deletions packages/propel/src/ui/checkbox/checkbox-visual.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CheckboxGlyph } from "./checkbox-glyph";
import { checkboxVariants, type CheckboxTone } from "./variants";
import { checkboxIndicatorVariants, checkboxVariants, type CheckboxTone } from "./variants";

export type CheckboxVisualProps = {
/** Resting color of the box. `danger` is the Figma "Error" state. */
Expand All @@ -22,7 +22,11 @@ export function CheckboxVisual({ tone, checked, indeterminate, disabled }: Check
data-disabled={disabled ? "" : undefined}
className={checkboxVariants({ tone })}
>
{checked || indeterminate ? <CheckboxGlyph indeterminate={indeterminate} /> : null}
{checked || indeterminate ? (
<span className={checkboxIndicatorVariants()}>
<CheckboxGlyph indeterminate={indeterminate} />
</span>
) : null}
</span>
);
}
47 changes: 42 additions & 5 deletions packages/propel/src/ui/checkbox/checkbox.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Repeat } from "lucide-react";
import { expect } from "storybook/test";

import { Checkbox, CheckboxGlyph, CheckboxIndicator } from "./index";
import {
Checkbox,
CheckboxGlyph,
CheckboxIndicator,
CheckboxInlineStartNode,
CheckboxLabel,
} from "./index";

// UI-tier story: composes the ATOMIC checkbox parts. `Checkbox` is the bare box
// (Base UI `Checkbox.Root`); the tick/dash only shows when you nest a
// `CheckboxIndicator` wrapping a `CheckboxGlyph`. The components-tier `Checkbox`
// story shows the ready-made (label row + icon slot). Here you assemble the raw
// parts and own the accessible name via `aria-label`.
// `CheckboxIndicator` wrapping a `CheckboxGlyph`. A labeled row is the
// `CheckboxLabel` chip wrapping the box, an optional `CheckboxInlineStartNode`
// icon slot, and the text. The components-tier `Checkbox` story shows the
// ready-made version. Here you assemble the raw parts and own the accessible name.
const meta = {
title: "UI/Checkbox",
component: Checkbox,
subcomponents: { CheckboxIndicator, CheckboxGlyph },
subcomponents: { CheckboxIndicator, CheckboxGlyph, CheckboxLabel, CheckboxInlineStartNode },
args: { tone: "neutral", "aria-label": "Example" },
} satisfies Meta<typeof Checkbox>;

Expand Down Expand Up @@ -76,3 +84,32 @@ export const States: Story = {
await expect(checked).not.toBeEmptyDOMElement();
},
};

/**
* A labeled row assembled from the atomic parts: a `CheckboxLabel` chip wrapping the box, an
* optional `CheckboxInlineStartNode` icon slot, and the text. The label is associated with the box
* via `htmlFor`, so clicking anywhere in the row toggles the box.
*/
export const Labeled: Story = {
parameters: { controls: { disable: true } },
render: () => (
<CheckboxLabel disabled={false} htmlFor="ui-checkbox-labeled">
<Checkbox id="ui-checkbox-labeled" tone="neutral">
<CheckboxIndicator>
<CheckboxGlyph />
</CheckboxIndicator>
</Checkbox>
<CheckboxInlineStartNode>
<Repeat aria-hidden />
</CheckboxInlineStartNode>
Sync automatically
</CheckboxLabel>
),
play: async ({ canvas, userEvent }) => {
const box = canvas.getByRole("checkbox");
await expect(box).toHaveAttribute("aria-checked", "false");
// Clicking the label text toggles the associated box.
await userEvent.click(canvas.getByText("Sync automatically"));
await expect(box).toHaveAttribute("aria-checked", "true");
},
};
6 changes: 6 additions & 0 deletions packages/propel/src/ui/checkbox/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
export { Checkbox, type CheckboxProps, type CheckboxTone } from "./checkbox";
export { CheckboxIndicator, type CheckboxIndicatorProps } from "./checkbox-indicator";
export { CheckboxGlyph } from "./checkbox-glyph";
export { CheckboxLabel, type CheckboxLabelProps } from "./checkbox-label";
export {
CheckboxInlineStartNode,
type CheckboxInlineStartNodeProps,
} from "./checkbox-inline-start-node";
export { CheckboxVisual, type CheckboxVisualProps } from "./checkbox-visual";
export type { CheckboxLabelDisabled } from "./variants";
59 changes: 59 additions & 0 deletions packages/propel/src/ui/checkbox/variants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { cva, cx, type VariantProps } from "class-variance-authority";

import { nodeSlotClass } from "../../internal/node-slot";

export const checkboxVariants = cva(
cx(
"inline-flex size-4 shrink-0 items-center justify-center rounded-sm border-sm align-top [--node-size:0.75rem]",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-accent-strong focus-visible:ring-offset-1",
"data-checked:border-transparent data-checked:bg-accent-primary data-checked:text-icon-on-color",
"data-indeterminate:border-transparent data-indeterminate:bg-accent-primary data-indeterminate:text-icon-on-color",
"data-disabled:cursor-not-allowed data-disabled:border-disabled data-disabled:bg-transparent",
"data-disabled:data-checked:border-transparent data-disabled:data-checked:bg-layer-disabled data-disabled:data-checked:text-icon-disabled",
"data-disabled:data-indeterminate:border-transparent data-disabled:data-indeterminate:bg-layer-disabled data-disabled:data-indeterminate:text-icon-disabled",
),
{
variants: {
tone: {
neutral: "border-icon-tertiary",
danger: "border-danger-strong",
},
},
},
);

type CheckboxVariantProps = VariantProps<typeof checkboxVariants>;

export type CheckboxTone = NonNullable<CheckboxVariantProps["tone"]>;

// The indicator wrapper centers its content (the glyph) inside the box and sizes
// that single child to the box's `--node-size` (the node-slot pattern), so the glyph
// never bakes its own size. No adjustable axes on the indicator itself.
export const checkboxIndicatorVariants = cva(cx(nodeSlotClass, "text-current"));

// The clickable label row that wraps the box + optional icon + label text.
// `disabled` mirrors the `disabled` prop; the cursor and hover background change.
export const checkboxLabelVariants = cva(
cx(
"inline-flex items-center gap-2 rounded-sm px-2 py-1",
"text-13 text-secondary transition-colors",
),
{
variants: {
disabled: {
true: "cursor-not-allowed",
false: "cursor-pointer hover:bg-layer-transparent-hover",
},
},
},
);

type CheckboxLabelVariantProps = VariantProps<typeof checkboxLabelVariants>;

export type CheckboxLabelDisabled = NonNullable<CheckboxLabelVariantProps["disabled"]>;

// The inline-start icon slot between the box and the label text.
export const checkboxInlineStartNodeVariants = cva(
cx(nodeSlotClass, "text-icon-secondary [--node-size:0.875rem]"),
);
26 changes: 0 additions & 26 deletions packages/propel/src/ui/checkbox/variants.tsx

This file was deleted.