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
15 changes: 12 additions & 3 deletions packages/propel/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,10 @@ from tiers below it, never above. `internal/` is shared implementation usable by
`base` primitive, or an intrinsic element via Base UI's `useRender`
(`useRender({ defaultTagName, render, props: mergeProps(defaults, props) })`, defaults first).
No `Context.Provider` wrap, no second element/frame, no baked default child (a slot renders
`{children}`, never `children ?? <Default/>`). If you need more structure, add a NEW named
`ui` part and compose the parts in `components`.
`{children}`, never `children ?? <Default/>`), and no authored `render={<X/>}` that injects
another component/behavior — forwarding the consumer's own `render` (the `useRender` mechanism)
IS the render-capability and is fine; baking a specific render target is composition. If you
need more structure, add a NEW named `ui` part and compose the parts in `components`.

2. **All composition lives in `components`** (and `patterns`). Providers, multi-element frames,
defaults, and wiring belong here — never in `ui`.
Expand All @@ -53,6 +55,11 @@ imports `lucide-react`, rendering an icon as `{children}` (a slot) and sizing it
carries no `className`, so its size comes from the `ui` cva). `lucide-react` may be imported
**only** in `components` source and in stories — never in `ui`, `base`, or `internal` source.

2b. **`ui`/`base` stories stay in-tier.** A `ui` (or `base`) story imports only from `ui`/`base`
(+ Base UI and external libs like `lucide-react`) — NEVER from `components`. To show what a
`components` ready-made composes (e.g. a toolbar toggle = `ToolbarButton` + Base UI `Toggle`), build
it from the `ui` atoms inline in the story.

3. **`cva`/`cx` live only in `ui`; `className`/`style` exposure stops at `base`.** `base` follows
Base UI exactly and **exposes `className`/`style`** (it is unstyled — `ui` is what styles it).
`ui` and `components` do **not** expose `className`/`style`: `ui` bakes its styling into a cva
Expand Down Expand Up @@ -162,9 +169,11 @@ govern this: `variant` is too vague (6c), and native HTML/CSS attribute names ar
| `presentation` | **shape** of a repeated entry | NavigationMenuLink `item·card` |
| `mode` | **behavior mode** of one component | Table `table·spreadsheet` |
| `surface` | host background it adapts to | `background·fill` |
| `density` | compactness | `short·default·auto` |
| `density` | compactness | `comfortable·compact` |
| `elevation` | draws its own raised surface vs flat | `raised·flat` |
| `orientation` | layout axis | `horizontal·vertical` |
| `side` | which edge a panel attaches to | Drawer `start·end` |
| `visibility` | when a transient affordance shows | ScrollArea `auto·always` |

**Why these names — decided on merit, not on what already shipped:**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,15 @@ export const RendersInput: Story = {
await expect(canvas.getByRole("combobox", { name: "Container image" })).toBeInTheDocument();
},
};

/** Setting `error` marks the field invalid, which recolors the input group border to danger. */
export const Invalid: Story = {
args: { error: "Enter a container image." },
play: async ({ canvas }) => {
const input = canvas.getByRole("combobox", { name: "Container image" });
await expect(input).toHaveAttribute("aria-invalid", "true");
await expect(input).toHaveAttribute("data-invalid");
const group = input.closest<HTMLElement>(":has([data-invalid])");
await expect(group).toHaveClass("has-[[data-invalid]]:border-danger-strong");
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export function AutocompleteField({
return (
<Field name={name} disabled={disabled} invalid={error != null || undefined}>
<Autocomplete disabled={disabled} items={items} {...autocompleteProps}>
<FieldLabel magnitude={magnitude}>{label}</FieldLabel>
<FieldLabel magnitude={magnitude} inset={false}>
{label}
</FieldLabel>
<AutocompleteInputGroup>
<AutocompleteInput placeholder={placeholder} />
<AutocompleteClear aria-label={`Clear ${controlLabel}`} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ export const Default: Story = {
render: (args) => (
<Field name="containerImage">
<Autocomplete {...args}>
<FieldLabel magnitude="md">Container image</FieldLabel>
<FieldLabel magnitude="md" inset={false}>
Container image
</FieldLabel>
<AutocompleteInputGroup>
<AutocompleteInput placeholder="e.g. docker.io/library/node:latest" />
<AutocompleteClear />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ const meta = {
name: "emailUpdates",
label: "Email updates",
magnitude: "md",
tone: "neutral",
value: "enabled",
},
} satisfies Meta<typeof CheckboxField>;
Expand All @@ -27,6 +26,39 @@ export const Default: Story = {
},
};

/**
* Setting `error` marks the field invalid. Base UI's `Field.Root` propagates that validity to the
* checkbox box as `data-invalid`, and the box recolors its border to `danger` — no `tone` prop. A
* resting field is shown alongside so the danger border is visibly (and assertably) different.
*/
export const Invalid: Story = {
parameters: { controls: { disable: true } },
render: () => (
<div className="flex flex-col gap-4">
<CheckboxField name="resting" label="Resting" magnitude="md" value="a" />
<CheckboxField
name="terms"
label="Accept the terms"
magnitude="md"
value="b"
error="You must accept the terms to continue."
/>
</div>
),
play: async ({ canvas }) => {
const [resting, invalid] = canvas.getAllByRole("checkbox");
// The error-free field leaves the box in its resting (non-invalid) state.
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");
await expect(invalid).toHaveClass("data-invalid:border-danger-strong");
// ...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,
);
},
};

export const RendersCheckbox: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas }) => {
Expand Down
16 changes: 4 additions & 12 deletions packages/propel/src/components/checkbox-field/checkbox-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,15 @@ import {
CheckboxFieldControl,
type CheckboxFieldControlProps,
} from "../../internal/checkbox-field-control";
import type { CheckboxTone } from "../../ui/checkbox/index";
import { Field } from "../../ui/field/field";
import { FieldItem } from "../../ui/field/field-item";
import { FieldItemContent } from "../../ui/field/field-item-content";
import type { FieldMagnitude } from "../../ui/field/variants";
import { FieldItemContent } from "../field";
import { FieldHelperText } from "../field/field-helper-text";

export type CheckboxFieldProps = Omit<
CheckboxFieldControlProps,
"aria-label" | "label" | "inlineStartNode" | "tone"
"aria-label" | "label" | "inlineStartNode"
> & {
/** Helper text shown below the control. Replaced by `error` when an error is set. */
hint?: React.ReactNode;
Expand All @@ -25,8 +24,6 @@ export type CheckboxFieldProps = Omit<
magnitude: FieldMagnitude;
/** Optional supporting text announced as the checkbox description. */
description?: React.ReactNode;
/** Resting color of the box. */
tone: CheckboxTone;
};

/** Ready-to-use single checkbox field with label, description, and helper/error text. */
Expand All @@ -36,19 +33,14 @@ export function CheckboxField({
hint,
error,
magnitude,
tone,
name,
disabled,
...controlProps
}: CheckboxFieldProps) {
return (
<Field
name={name}
disabled={disabled}
invalid={error != null || tone === "danger" || undefined}
>
<Field name={name} disabled={disabled} invalid={error != null || undefined}>
<FieldItem disabled={disabled}>
<CheckboxFieldControl tone={tone} disabled={disabled} {...controlProps} />
<CheckboxFieldControl disabled={disabled} {...controlProps} />
<FieldItemContent magnitude={magnitude} description={description}>
{label}
</FieldItemContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,38 +5,34 @@ import {
type CheckboxFieldControlProps,
} from "../../internal/checkbox-field-control";
import { useFieldOptionMagnitude } from "../../internal/field-option-magnitude";
import type { CheckboxTone } from "../../ui/checkbox/index";
import { FieldItem } from "../../ui/field/field-item";
import { FieldItemContent } from "../../ui/field/field-item-content";
import type { FieldMagnitude } from "../../ui/field/variants";
import { FieldItemContent } from "../field";

export type CheckboxGroupFieldOptionProps = Omit<
CheckboxFieldControlProps,
"aria-label" | "label" | "inlineStartNode" | "tone"
"aria-label" | "label" | "inlineStartNode"
> & {
/** Visible option label. */
label: React.ReactNode;
/** Optional supporting text announced as the checkbox description. */
description?: React.ReactNode;
/** Label and description size. Inherited from `CheckboxGroupField` when omitted. */
magnitude?: FieldMagnitude;
/** Resting color of the box. */
tone: CheckboxTone;
};

/** A checkbox option row for use inside `CheckboxGroupField` or a custom `CheckboxGroup`. */
export function CheckboxGroupFieldOption({
label,
description,
magnitude: magnitudeProp,
tone,
...props
}: CheckboxGroupFieldOptionProps) {
const magnitude = useFieldOptionMagnitude(magnitudeProp);

return (
<FieldItem disabled={props.disabled}>
<CheckboxFieldControl tone={tone} {...props} />
<CheckboxFieldControl {...props} />
<FieldItemContent magnitude={magnitude} description={description}>
{label}
</FieldItemContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,8 @@ const meta = {
defaultValue: ["email"],
children: (
<>
<CheckboxGroupFieldOption value="email" label="Email" tone="neutral" />
<CheckboxGroupFieldOption
value="slack"
label="Slack"
description="Workspace alerts."
tone="neutral"
/>
<CheckboxGroupFieldOption value="email" label="Email" />
<CheckboxGroupFieldOption value="slack" label="Slack" description="Workspace alerts." />
</>
),
},
Expand All @@ -34,6 +29,21 @@ type Story = StoryObj<typeof meta>;

export const Default: Story = { args: { hint: "At least one channel is recommended." } };

/**
* Setting `error` marks the whole group invalid. Base UI's `Field.Root` propagates that validity to
* every checkbox box as `data-invalid`, so each option's border recolors to `danger` automatically
* — no per-option `tone`.
*/
export const Invalid: Story = {
args: { error: "Choose at least one channel." },
play: async ({ canvas }) => {
for (const box of canvas.getAllByRole("checkbox")) {
await expect(box).toHaveAttribute("data-invalid");
await expect(box).toHaveClass("data-invalid:border-danger-strong");
}
},
};

export const RendersGroup: Story = {
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ export const Default: Story = {
args: { density: "comfortable", defaultValue: ["https"] },
render: (args) => (
<CheckboxGroup {...args} aria-label="Allowed protocols">
<Checkbox tone="neutral" value="http" label="HTTP" />
<Checkbox tone="neutral" value="https" label="HTTPS" />
<Checkbox tone="neutral" value="ssh" label="SSH" />
<Checkbox value="http" label="HTTP" />
<Checkbox value="https" label="HTTPS" />
<Checkbox value="ssh" label="SSH" />
</CheckboxGroup>
),
play: async ({ canvas }) => {
Expand All @@ -41,12 +41,12 @@ export const Density: Story = {
render: () => (
<div className="flex items-start gap-10">
<CheckboxGroup density="comfortable" defaultValue={["daily"]} aria-label="Comfortable">
<Checkbox tone="neutral" value="daily" label="Daily" />
<Checkbox tone="neutral" value="weekly" label="Weekly" />
<Checkbox value="daily" label="Daily" />
<Checkbox value="weekly" label="Weekly" />
</CheckboxGroup>
<CheckboxGroup density="compact" defaultValue={["daily"]} aria-label="Compact">
<Checkbox tone="neutral" value="daily" label="Daily" />
<Checkbox tone="neutral" value="weekly" label="Weekly" />
<Checkbox value="daily" label="Daily" />
<Checkbox value="weekly" label="Weekly" />
</CheckboxGroup>
</div>
),
Expand All @@ -58,8 +58,8 @@ export const SelectionBehavior: Story = {
args: { density: "comfortable", defaultValue: [] },
render: (args) => (
<CheckboxGroup {...args} aria-label="Allowed protocols">
<Checkbox tone="neutral" value="http" label="HTTP" />
<Checkbox tone="neutral" value="https" label="HTTPS" />
<Checkbox value="http" label="HTTP" />
<Checkbox value="https" label="HTTPS" />
</CheckboxGroup>
),
play: async ({ canvas, userEvent }) => {
Expand Down
Loading