Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c00f6eb
fix(badge): update badge variant to use rounded-sm for improved styling
codingwolf-at Jul 6, 2026
232b256
refactor: wrap checkbox, radio, and switch controls in FieldItemContr…
codingwolf-at Jul 6, 2026
56f419b
refactor(autocomplete, combobox, select): update description prop beh…
codingwolf-at Jul 6, 2026
3c967ce
refactor(table): enhance editable cell behavior and improve table var…
codingwolf-at Jul 6, 2026
3924b3c
refactor(pagination): unify corner radius for pagination items and ar…
codingwolf-at Jul 6, 2026
48f9517
refactor(tabs): enhance disabled tab styling and update comments for …
codingwolf-at Jul 6, 2026
a2df919
refactor(autocomplete): tighten right padding for input group with tr…
codingwolf-at Jul 6, 2026
144625c
refactor(pill): update border-radius to medium and enhance comments f…
codingwolf-at Jul 6, 2026
1868f80
refactor(otp-field, control-group): adjust size specifications for co…
codingwolf-at Jul 6, 2026
ac48957
refactor(button, control-chrome, spinner): enhance animations and imp…
codingwolf-at Jul 6, 2026
f3ba08f
refactor(link-chrome): update secondary link variant and adjust text …
codingwolf-at Jul 6, 2026
d9f56ca
feat(drawer): introduce DrawerHeader component with built-in close fu…
codingwolf-at Jul 6, 2026
db39f4c
refactor(popover): enhance panel row structure and improve alignment
codingwolf-at Jul 7, 2026
007e8ea
refactor(breadcrumb): update color variants and improve height consis…
codingwolf-at Jul 7, 2026
cff06d0
refactor(select-field): replace SelectLabel with FieldLabel for consi…
codingwolf-at Jul 7, 2026
ed29335
refactor(checkbox, radio, switch): unify transition durations and enh…
codingwolf-at Jul 7, 2026
a43755f
refactor(context-menu): update selection indicators and improve item …
codingwolf-at Jul 7, 2026
eb456ba
fix(checkbox-field, checkbox-group-field): update stale invalid-borde…
codingwolf-at Jul 7, 2026
acbaca4
refactor(menu-item): update check indicator positioning and improve c…
codingwolf-at Jul 7, 2026
f188b66
feat(preview-card): enhance component structure with new elements and…
codingwolf-at Jul 7, 2026
31d5212
feat(combobox): enhance chip rendering with overflow management and c…
codingwolf-at Jul 7, 2026
6ccb982
refactor(menu-item, select-field): improve layout and component struc…
codingwolf-at Jul 7, 2026
55bf0cd
refactor(checkbox-group): rename parent checkbox to select-all and en…
codingwolf-at Jul 7, 2026
5cdcdae
refactor(popover): update PanelRow to use label prop for improved cla…
codingwolf-at Jul 7, 2026
05d50e6
refactor(popover, context-menu, menu): standardize height across comp…
codingwolf-at Jul 7, 2026
8cbf8a5
refactor(spinner, animations): update animation naming conventions fo…
codingwolf-at Jul 7, 2026
267cf8c
refactor(checkbox-group): simplify checkbox structure and enhance doc…
codingwolf-at Jul 7, 2026
3e60d0a
feat(combobox): introduce ComboboxChip and ComboboxChipRemove components
codingwolf-at Jul 8, 2026
1d7871f
refactor(spinner, animations): update animation naming for clarity
codingwolf-at Jul 8, 2026
4a7256f
refactor(combobox): remove ComboboxChipRemove component and update usage
codingwolf-at Jul 8, 2026
37a3bf3
fix(rebase): reconcile drawer + select-field with approved review state
codingwolf-at Jul 8, 2026
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 @@ -20,7 +20,7 @@ export type AutocompleteFieldProps<Value = string> = Omit<
AutocompleteProps<Value>,
"children" | "items"
> & {
/** Supporting text shown below the input. */
/** Supporting text shown below the input. Replaced by `error` when an error is set. */
description?: React.ReactNode;
/** The clear control (e.g. an `IconButton`) carrying its own localizable `aria-label`. */
clear: React.ReactElement;
Expand Down Expand Up @@ -76,7 +76,7 @@ export function AutocompleteField<Value = string>({
<BaseAutocomplete.Clear render={clear} />
<BaseAutocomplete.Trigger render={trigger} />
</BaseAutocomplete.InputGroup>
{description != null ? (
{error == null && description != null ? (
<FieldDescription magnitude={magnitude}>{description}</FieldDescription>
) : null}
<BaseAutocomplete.Portal>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function BreadcrumbMenuTrigger({ icon, label, ...props }: BreadcrumbMenuT
<BaseMenu.Trigger render={<BreadcrumbTrigger group />} {...props}>
{icon}
{label}
<DisclosureIndicator motion="disclose" tint="tertiary" magnitude="sm">
<DisclosureIndicator motion="disclose" tint="secondary" magnitude="sm">
<ChevronDown />
</DisclosureIndicator>
</BaseMenu.Trigger>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ 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");
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type * as React from "react";

import { FieldItemControlGroup } from "../../elements/field/field-item-control-group";
import type { FieldMagnitude } from "../../elements/field/variants";
import {
CheckboxFieldControl,
Expand Down Expand Up @@ -40,7 +41,9 @@ export function CheckboxField({
return (
<Field name={name} disabled={disabled} invalid={error != null || undefined}>
<FieldItem disabled={disabled}>
<CheckboxFieldControl disabled={disabled} {...controlProps} />
<FieldItemControlGroup>
<CheckboxFieldControl disabled={disabled} {...controlProps} />
</FieldItemControlGroup>
<FieldItemContent magnitude={magnitude} description={description}>
{label}
</FieldItemContent>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type * as React from "react";

import { FieldItemControlGroup } from "../../elements/field/field-item-control-group";
import type { FieldMagnitude } from "../../elements/field/variants";
import {
CheckboxFieldControl,
Expand Down Expand Up @@ -31,7 +32,9 @@ export function CheckboxGroupFieldOption({

return (
<FieldItem disabled={props.disabled}>
<CheckboxFieldControl {...props} />
<FieldItemControlGroup>
<CheckboxFieldControl {...props} />
</FieldItemControlGroup>
<FieldItemContent magnitude={magnitude} description={description}>
{label}
</FieldItemContent>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,26 @@ export const Invalid: Story = {
};

/**
* Interaction test: every option's box propagates `data-invalid` and the danger border class.
* Tagged out of the sidebar/docs/manifest while still running under the default `test` tag.
* Interaction test: every option's box propagates `data-invalid`. The still-unchecked options show
* the danger border; "Email" starts `defaultValue`-checked, and a checked box keeps its transparent
* border (the accent fill alone communicates the value) even while the group is invalid. Tagged out
* of the sidebar/docs/manifest while still running under the default `test` tag.
*/
export const InvalidInteraction: Story = {
...Invalid,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas }) => {
const dangerBorder = getComputedStyle(document.documentElement)
.getPropertyValue("--border-danger-strong")
.trim();
for (const box of canvas.getAllByRole("checkbox")) {
await expect(box).toHaveAttribute("data-invalid");
await expect(box).toHaveClass("data-invalid:border-danger-strong");
const borderColor = getComputedStyle(box).borderColor;
if (box.getAttribute("aria-checked") === "true") {
await expect(borderColor).not.toBe(dangerBorder);
} else {
await expect(borderColor).toBe(dangerBorder);
}
}
},
};
Expand Down
211 changes: 97 additions & 114 deletions packages/propel/src/components/checkbox-group/checkbox-group.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import * as React from "react";
import { expect } from "storybook/test";

import { Checkbox } from "../checkbox/index";
Expand All @@ -18,8 +17,8 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;

// Every child value in the parent-checkbox stories; the group's `allValues` prop
// is what lets a `parent` checkbox derive its checked/indeterminate state.
// Every value in the select-all story; the group's `allValues` prop is what lets the `parent`
// checkbox derive its checked/indeterminate state from the flat list of rows.
const PROTOCOLS = ["http", "https", "ssh"];

/** Labeled checkbox rows; the first is selected by default. */
Expand Down Expand Up @@ -50,176 +49,160 @@ export const DefaultInteraction: Story = {
};

/**
* A `parent` checkbox controls the whole group: pass the group `allValues` and mark one row
* `parent`. With only some values selected it shows the indeterminate dash; toggling it checks or
* clears every row. Explicit child ids + `aria-controls` keep the parent's controls references
* valid (propel rows always carry their own label id).
* A **select-all** control (Base UI's `parent` checkbox): pass the group `allValues` and mark one
* row `parent`. Base UI derives that row's state from the flat list — checked when every row is on,
* the indeterminate dash when only some are, unchecked when none — and toggling it checks or clears
* every row at once. The control is an affordance over the whole group, not a value of its own, so
* label it plainly ("Select all"). Rows carry no explicit `id`: Base UI derives each child input id
* as `${groupId}-${value}` and auto-wires the parent's `aria-controls` to match (a custom `id` on a
* grouped child is ignored — base-ui#2691).
*/
export const ParentCheckbox: Story = {
export const SelectAll: Story = {
args: { density: "comfortable", defaultValue: ["https"], allValues: PROTOCOLS },
render: (args) => (
<CheckboxGroup {...args} aria-label="Allowed protocols">
<Checkbox parent label="All protocols" aria-controls={PROTOCOLS.join(" ")} />
<Checkbox id="http" value="http" label="HTTP" />
<Checkbox id="https" value="https" label="HTTPS" />
<Checkbox id="ssh" value="ssh" label="SSH" />
<Checkbox parent label="Select all" />
<Checkbox value="http" label="HTTP" />
<Checkbox value="https" label="HTTPS" />
<Checkbox value="ssh" label="SSH" />
</CheckboxGroup>
),
};

/**
* Interaction test: the parent reads `mixed` for a partial selection, checks every row when
* Interaction test: the select-all row reads `mixed` for a partial selection, checks every row when
* clicked, clears them on the next click, and returns to `mixed` when a single row is checked.
* Tagged out of the sidebar/docs/manifest while still running under the default `test` tag.
*/
export const ParentCheckboxInteraction: Story = {
...ParentCheckbox,
export const SelectAllInteraction: Story = {
...SelectAll,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas, userEvent }) => {
const parent = canvas.getByRole("checkbox", { name: "All protocols" });
const selectAll = canvas.getByRole("checkbox", { name: "Select all" });
const http = canvas.getByRole("checkbox", { name: "HTTP" });
const https = canvas.getByRole("checkbox", { name: "HTTPS" });
const ssh = canvas.getByRole("checkbox", { name: "SSH" });

// one of three values selected → the parent reports the indeterminate (mixed) state
await expect(parent).toHaveAttribute("aria-checked", "mixed");
// one of three values selected → the select-all reports the indeterminate (mixed) state
await expect(selectAll).toHaveAttribute("aria-checked", "mixed");

// clicking a mixed parent selects every value
await userEvent.click(parent);
await expect(parent).toHaveAttribute("aria-checked", "true");
// clicking a mixed select-all selects every value
await userEvent.click(selectAll);
await expect(selectAll).toHaveAttribute("aria-checked", "true");
await expect(http).toHaveAttribute("aria-checked", "true");
await expect(https).toHaveAttribute("aria-checked", "true");
await expect(ssh).toHaveAttribute("aria-checked", "true");

// clicking a fully-checked parent clears the group
await userEvent.click(parent);
await expect(parent).toHaveAttribute("aria-checked", "false");
// clicking a fully-checked select-all clears the group
await userEvent.click(selectAll);
await expect(selectAll).toHaveAttribute("aria-checked", "false");
await expect(http).toHaveAttribute("aria-checked", "false");
await expect(https).toHaveAttribute("aria-checked", "false");
await expect(ssh).toHaveAttribute("aria-checked", "false");

// checking a single row returns the parent to mixed
// checking a single row returns the select-all to mixed
await userEvent.click(http);
await expect(parent).toHaveAttribute("aria-checked", "mixed");
await expect(selectAll).toHaveAttribute("aria-checked", "mixed");
},
};

// Values for the nested-group stories: the outer group tracks the top-level permissions while
// the nested group tracks the user-management subset behind the "manage-users" value.
const MAIN_PERMISSIONS = ["view-dashboard", "manage-users", "access-reports"];
const MANAGEMENT_PERMISSIONS = ["create-user", "edit-user", "delete-user", "assign-roles"];
// The nested story models a real permission tree as two INDEPENDENT categories. Each category is its
// own group whose `allValues` are exactly that category's children, so its `parent` rolls up only
// its own children — never the sibling category.
const MANAGE_USERS_PERMISSIONS = ["create-user", "edit-user", "delete-user", "assign-roles"];
const MANAGE_CONTENT_PERMISSIONS = ["create-content", "edit-content", "publish-content"];

/**
* Groups nest: a nested `CheckboxGroup` (with its own `parent` row) manages a subset of values
* while the outer group tracks the top level. The two controlled selections stay in sync through
* `onValueChange` — completing the nested group checks its value into the outer one — and the outer
* parent's explicit `indeterminate` shows the dash while the nested selection is partial.
* A genuine parent/child hierarchy — distinct from the flat `SelectAll` control above. Each
* category ("Manage users", "Manage content") is its OWN `CheckboxGroup` whose `allValues` are
* exactly that category's children, so its `parent` row rolls up ONLY its own children: it checks
* when all of them are on, shows the indeterminate dash when only some are, and toggling it checks
* or clears its own children. The categories are independent — a row under "Manage users" never
* affects "Manage content". Both groups are uncontrolled (`defaultValue`), so Base UI owns every
* parent/child state with no manual syncing. "User permissions" is a plain heading, not a control.
*/
export const NestedParentCheckbox: Story = {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you want a convention for field ids:

form-name-field-value

args: { density: "comfortable", allValues: MAIN_PERMISSIONS },
render: function Render(args) {
const [mainValue, setMainValue] = React.useState<string[]>([]);
const [managementValue, setManagementValue] = React.useState<string[]>([]);
return (
parameters: { controls: { disable: true } },
args: { density: "comfortable" },
render: (args) => (
<div className="flex flex-col gap-4">
<p className="text-13 font-semibold text-primary">User permissions</p>
<CheckboxGroup
{...args}
aria-label="User permissions"
value={mainValue}
onValueChange={(value) => {
if (value.includes("manage-users")) {
setManagementValue(MANAGEMENT_PERMISSIONS);
} else if (managementValue.length === MANAGEMENT_PERMISSIONS.length) {
setManagementValue([]);
}
setMainValue(value);
}}
density={args.density}
aria-label="Manage users"
allValues={MANAGE_USERS_PERMISSIONS}
defaultValue={[]}
>
<Checkbox
parent
label="User permissions"
aria-controls={MAIN_PERMISSIONS.join(" ")}
indeterminate={
managementValue.length > 0 && managementValue.length !== MANAGEMENT_PERMISSIONS.length
}
/>
<Checkbox id="view-dashboard" value="view-dashboard" label="View dashboard" />
<Checkbox id="access-reports" value="access-reports" label="Access reports" />
<div className="ps-6">
<CheckboxGroup
density={args.density}
aria-label="Manage users"
allValues={MANAGEMENT_PERMISSIONS}
value={managementValue}
onValueChange={(value) => {
if (value.length === MANAGEMENT_PERMISSIONS.length) {
setMainValue((prev) =>
prev.includes("manage-users") ? prev : [...prev, "manage-users"],
);
} else {
setMainValue((prev) => prev.filter((v) => v !== "manage-users"));
}
setManagementValue(value);
}}
>
<Checkbox
parent
id="manage-users"
label="Manage users"
aria-controls={MANAGEMENT_PERMISSIONS.join(" ")}
/>
<Checkbox id="create-user" value="create-user" label="Create user" />
<Checkbox id="edit-user" value="edit-user" label="Edit user" />
<Checkbox id="delete-user" value="delete-user" label="Delete user" />
<Checkbox id="assign-roles" value="assign-roles" label="Assign roles" />
</CheckboxGroup>
<Checkbox parent label="Manage users" />
<div className="flex flex-col gap-2 ps-6">
<Checkbox value="create-user" label="Create user" />
<Checkbox value="edit-user" label="Edit user" />
<Checkbox value="delete-user" label="Delete user" />
<Checkbox value="assign-roles" label="Assign roles" />
</div>
</CheckboxGroup>
);
},
<CheckboxGroup
density={args.density}
aria-label="Manage content"
allValues={MANAGE_CONTENT_PERMISSIONS}
defaultValue={[]}
>
<Checkbox parent label="Manage content" />
<div className="flex flex-col gap-2 ps-6">
<Checkbox value="create-content" label="Create content" />
<Checkbox value="edit-content" label="Edit content" />
<Checkbox value="publish-content" label="Publish content" />
</div>
</CheckboxGroup>
</div>
),
};

/**
* Interaction test: one nested row turns the nested parent `mixed` and puts the indeterminate dash
* on the outer parent (its explicit `indeterminate` surfaces as `data-indeterminate`;
* `aria-checked` stays group-derived). Completing the nested group checks "manage-users" into the
* outer group, and the outer parent then selects and clears every value across both groups. Tagged
* out of the sidebar/docs/manifest while still running under the default `test` tag.
* Interaction test for the parent/child hierarchy: selecting one child turns ONLY its own parent
* `mixed` (the sibling category is untouched), completing every child checks that parent, toggling
* a parent checks/clears only its own children, and toggling one category never leaks into the
* other. Tagged out of the sidebar/docs/manifest while still running under the default `test` tag.
*/
export const NestedParentCheckboxInteraction: Story = {
...NestedParentCheckbox,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas, userEvent }) => {
const userPermissions = canvas.getByRole("checkbox", { name: "User permissions" });
const manageUsers = canvas.getByRole("checkbox", { name: "Manage users" });
const createUser = canvas.getByRole("checkbox", { name: "Create user" });
const viewDashboard = canvas.getByRole("checkbox", { name: "View dashboard" });
const editUser = canvas.getByRole("checkbox", { name: "Edit user" });
const deleteUser = canvas.getByRole("checkbox", { name: "Delete user" });
const assignRoles = canvas.getByRole("checkbox", { name: "Assign roles" });
const manageContent = canvas.getByRole("checkbox", { name: "Manage content" });
const createContent = canvas.getByRole("checkbox", { name: "Create content" });

// nothing selected anywhere → both parents read clear
await expect(userPermissions).toHaveAttribute("aria-checked", "false");
await expect(manageUsers).toHaveAttribute("aria-checked", "false");
await expect(manageContent).toHaveAttribute("aria-checked", "false");

// one nested row → the nested parent turns mixed and the outer parent shows the dash
// through its explicit `indeterminate` wiring
// selecting one child turns ONLY its own parent mixed; the sibling category is untouched
await userEvent.click(createUser);
await expect(manageUsers).toHaveAttribute("aria-checked", "mixed");
await expect(userPermissions).toHaveAttribute("data-indeterminate");
await expect(manageContent).toHaveAttribute("aria-checked", "false");
await expect(createContent).toHaveAttribute("aria-checked", "false");

// completing the nested group syncs "manage-users" into the outer group → outer parent mixed
await userEvent.click(manageUsers);
// completing every child of a category checks its parent — the sibling stays clear
await userEvent.click(editUser);
await userEvent.click(deleteUser);
await userEvent.click(assignRoles);
await expect(manageUsers).toHaveAttribute("aria-checked", "true");
await expect(createUser).toHaveAttribute("aria-checked", "true");
await expect(userPermissions).toHaveAttribute("aria-checked", "mixed");
await expect(manageContent).toHaveAttribute("aria-checked", "false");

// checking the outer parent selects every value in both groups
await userEvent.click(userPermissions);
await expect(userPermissions).toHaveAttribute("aria-checked", "true");
await expect(viewDashboard).toHaveAttribute("aria-checked", "true");
await expect(manageUsers).toHaveAttribute("aria-checked", "true");
// toggling a checked parent clears only its own children
await userEvent.click(manageUsers);
await expect(manageUsers).toHaveAttribute("aria-checked", "false");
await expect(createUser).toHaveAttribute("aria-checked", "false");
await expect(assignRoles).toHaveAttribute("aria-checked", "false");

// clearing the outer parent empties both groups
await userEvent.click(userPermissions);
await expect(userPermissions).toHaveAttribute("aria-checked", "false");
await expect(viewDashboard).toHaveAttribute("aria-checked", "false");
// toggling the OTHER parent checks only its own children — no leak into the first category
await userEvent.click(manageContent);
await expect(manageContent).toHaveAttribute("aria-checked", "true");
await expect(createContent).toHaveAttribute("aria-checked", "true");
await expect(manageUsers).toHaveAttribute("aria-checked", "false");
await expect(createUser).toHaveAttribute("aria-checked", "false");
},
Expand Down
Loading