diff --git a/packages/propel/src/components/accordion/accordion.stories.tsx b/packages/propel/src/components/accordion/accordion.stories.tsx
index fa686893..2cad8b3f 100644
--- a/packages/propel/src/components/accordion/accordion.stories.tsx
+++ b/packages/propel/src/components/accordion/accordion.stories.tsx
@@ -1,7 +1,9 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CircleHelp } from "lucide-react";
+import * as React from "react";
import { expect } from "storybook/test";
+import { Button } from "../button";
import { Icon } from "../icon";
import {
Accordion,
@@ -162,6 +164,77 @@ export const MultipleItems: Story = {
),
};
+/**
+ * A single item can opt out with `disabled` (Base UI dims the trigger, sets the native `disabled`
+ * attribute, and ignores pointer/keyboard toggling). Here the middle item is disabled while the
+ * others stay interactive; the first is expanded by default.
+ */
+export const Disabled: Story = {
+ parameters: { controls: { disable: true } },
+ render: (args) => (
+
+ {ITEMS.map((item) => (
+
+
+
+
+ {item.body}
+
+ ))}
+
+ ),
+};
+
+/**
+ * Controlled mode: `value` + `onValueChange` hand ownership of the open set to the consumer. The
+ * external **Expand all** / **Collapse all** buttons mutate that state directly (proving the
+ * accordion holds none of its own), while trigger clicks flow back through `onValueChange`. Uses
+ * `multiple` so several panels can be open at once.
+ */
+export const Controlled: Story = {
+ parameters: { controls: { disable: true } },
+ render: function Render(args) {
+ const [value, setValue] = React.useState(["what"]);
+ return (
+
+ );
+ },
+};
+
/**
* Behavior test: clicking a collapsed trigger expands its panel and flips `aria-expanded` to true
* (a `region` appears); clicking again collapses it.
@@ -274,3 +347,61 @@ export const KeyboardToggle: Story = {
await expect(trigger).toHaveAttribute("aria-expanded", "false");
},
};
+
+/**
+ * Behavior twin of `Disabled`: the disabled trigger carries the native `disabled` attribute and
+ * clicking it does nothing (`aria-expanded` stays false, no `region` appears), while a sibling
+ * still toggles normally.
+ */
+export const DisabledInteraction: Story = {
+ ...Disabled,
+ tags: ["!dev", "!autodocs", "!manifest"],
+ play: async ({ canvas, userEvent }) => {
+ const disabled = canvas.getByRole("button", { name: "How does pricing work?" });
+ // Base UI marks the row disabled via `aria-disabled`/`data-disabled` (keeping it focusable per
+ // the APG) rather than the native `disabled` attribute; either way it refuses to toggle.
+ await expect(disabled).toHaveAttribute("aria-disabled", "true");
+ await expect(disabled).toHaveAttribute("data-disabled");
+ await expect(disabled).toHaveAttribute("aria-expanded", "false");
+ await userEvent.click(disabled);
+ await expect(disabled).toHaveAttribute("aria-expanded", "false");
+ await expect(canvas.queryByRole("region", { name: "How does pricing work?" })).toBeNull();
+
+ // A sibling item is unaffected and still toggles.
+ const enabled = canvas.getByRole("button", { name: "Can I import my existing data?" });
+ await userEvent.click(enabled);
+ await expect(enabled).toHaveAttribute("aria-expanded", "true");
+ },
+};
+
+/**
+ * Behavior twin of `Controlled`: the external buttons drive the open set (proving the accordion is
+ * fully controlled), and a trigger click routes through `onValueChange` back into that same state.
+ */
+export const ControlledInteraction: Story = {
+ ...Controlled,
+ tags: ["!dev", "!autodocs", "!manifest"],
+ play: async ({ canvas, userEvent }) => {
+ const triggers = ITEMS.map((item) => canvas.getByRole("button", { name: item.label }));
+
+ // The first item starts open from the controlling state.
+ await expect(triggers[0]).toHaveAttribute("aria-expanded", "true");
+ await expect(triggers[1]).toHaveAttribute("aria-expanded", "false");
+
+ // Expand all: external state opens every panel simultaneously (multiple mode).
+ await userEvent.click(canvas.getByRole("button", { name: "Expand all" }));
+ for (const trigger of triggers) {
+ await expect(trigger).toHaveAttribute("aria-expanded", "true");
+ }
+
+ // Collapse all: external state closes every panel.
+ await userEvent.click(canvas.getByRole("button", { name: "Collapse all" }));
+ for (const trigger of triggers) {
+ await expect(trigger).toHaveAttribute("aria-expanded", "false");
+ }
+
+ // A trigger click still flows through onValueChange into the controlling state.
+ await userEvent.click(triggers[1]);
+ await expect(triggers[1]).toHaveAttribute("aria-expanded", "true");
+ },
+};
diff --git a/packages/propel/src/elements/accordion/accordion.stories.tsx b/packages/propel/src/elements/accordion/accordion.stories.tsx
index f4c621fa..87aa8ed3 100644
--- a/packages/propel/src/elements/accordion/accordion.stories.tsx
+++ b/packages/propel/src/elements/accordion/accordion.stories.tsx
@@ -59,6 +59,12 @@ const ITEMS = [
},
];
+// A title long enough to wrap onto several lines inside the framed 474px canvas — exercises
+// `accordionTriggerTitleVariants` (`min-w-0 flex-1`): the label grows, wraps, and shrinks rather
+// than pushing the trailing caret off the row.
+const LONG_TITLE =
+ "Can I connect Plane to my existing tools and migrate all of my historical project data without losing the relationships between issues, cycles, and modules?";
+
/**
* The full anatomy assembled statically: `Accordion` › `AccordionItem` › `AccordionHeader` ›
* `AccordionTrigger` (leading internal `Icon`, growing `AccordionTriggerTitle`, trailing internal
@@ -168,7 +174,10 @@ export const States: Story = {
-
+ {/* Real accordions disable at the ITEM, so Base UI sets `data-disabled`/`aria-disabled`
+ (the trigger stays focusable) — NOT the native `disabled` attribute. Pin that spelling
+ so the dimming reflects what ships. */}
+ Disabled
@@ -208,5 +217,180 @@ export const StatesCanary: Story = {
}
await expect(getComputedStyle(panel).height).toBe("0px");
}
+
+ // The compiled `data-disabled:opacity-60` selector dims the disabled row even though Base UI
+ // uses `data-disabled` (not the native `disabled` attribute), so `:disabled` alone wouldn't fire.
+ const disabled = canvas.getByRole("button", { name: "Disabled" });
+ await expect(Number(getComputedStyle(disabled).opacity)).toBeLessThan(1);
+ },
+};
+
+/**
+ * The same anatomy under `dir="rtl"`: the leading `Icon` and the growing title flip to the
+ * inline-start (right) edge and the `DisclosureIndicator` to the inline-end (left). The caret's
+ * `motion="disclose"` selectors are RTL-mirrored — the closed row's caret points inline-end (left,
+ * `rtl:rotate-90`) while the open row's points down (`rtl:group-data-panel-open:rotate-0`).
+ */
+export const RTL: Story = {
+ parameters: { controls: { disable: true } },
+ render: () => (
+
+
+
+
+
+
+
+
+ Open — caret points down
+
+
+
+
+
+
+
+ The panel content, laid out right-to-left.
+
+
+
+
+
+
+
+
+
+ Closed — caret points inline-end
+
+
+
+
+
+
+
+
+ ),
+};
+
+/**
+ * CSS canary (rule 2b): asserts the caret's RTL-mirrored selectors compiled — a closed caret
+ * rotates the opposite way under `dir="rtl"` (`rtl:rotate-90`) than under LTR (`-rotate-90`).
+ * Tagged out of the sidebar/docs/manifest while still running under the default `test` tag.
+ */
+export const RTLCanary: Story = {
+ tags: ["!dev", "!autodocs", "!manifest"],
+ parameters: { controls: { disable: true } },
+ render: () => (
+ <>
+
+
+
+
+ LTR closed
+
+
+
+
+
+
+
+
+
+
+
+
+ RTL closed
+
+
+
+
+
+
+
+
+ >
+ ),
+ play: async ({ canvas }) => {
+ const caretRotate = (name: string) => {
+ const caret = canvas.getByRole("button", { name }).querySelector("[aria-hidden]");
+ if (!(caret instanceof HTMLElement)) throw new Error(`missing caret in "${name}" trigger`);
+ return getComputedStyle(caret).rotate;
+ };
+ // The compiled `rtl:rotate-90` selector mirrors the closed caret away from LTR's `-rotate-90`.
+ await expect(caretRotate("LTR closed")).not.toBe(caretRotate("RTL closed"));
+ },
+};
+
+/**
+ * A trigger whose title is too long for one line: `accordionTriggerTitleVariants` (`min-w-0
+ * flex-1`) wraps it onto multiple lines and keeps it from shoving the trailing caret past the row's
+ * edge. The leading icon and caret stay centered against the wrapped block.
+ */
+export const LongText: Story = {
+ parameters: { controls: { disable: true } },
+ render: () => (
+
+
+
+
+
+
+
+ {LONG_TITLE}
+
+
+
+
+
+
+
+ The panel body sits below the wrapped multi-line title.
+
+
+
+
+ ),
+};
+
+/**
+ * CSS canary (rule 2b): asserts the title's `min-w-0 flex-1` actually wraps — a long title makes
+ * its row taller than a short one (multi-line), while `min-w-0` keeps the wrapped title from
+ * pushing the row wider than its container (no horizontal overflow). Tagged out of the
+ * sidebar/docs/manifest while still running under the default `test` tag.
+ */
+export const LongTextCanary: Story = {
+ tags: ["!dev", "!autodocs", "!manifest"],
+ parameters: { controls: { disable: true } },
+ render: () => (
+
+
+
+
+ Short
+
+
+
+
+
+
+
+
+
+ {LONG_TITLE}
+
+
+
+
+
+
+
+ ),
+ play: async ({ canvas }) => {
+ const short = canvas.getByRole("button", { name: "Short" });
+ const long = canvas.getByRole("button", { name: LONG_TITLE });
+ // The long title wraps onto multiple lines, making its row taller than the single-line row…
+ await expect(long.clientHeight).toBeGreaterThan(short.clientHeight);
+ // …while `min-w-0` keeps the wrapped title from pushing the row wider than its container.
+ await expect(long.scrollWidth).toBeLessThanOrEqual(long.clientWidth);
},
};
diff --git a/packages/propel/src/elements/accordion/variants.ts b/packages/propel/src/elements/accordion/variants.ts
index 6654ae2f..e7012684 100644
--- a/packages/propel/src/elements/accordion/variants.ts
+++ b/packages/propel/src/elements/accordion/variants.ts
@@ -16,14 +16,21 @@ export const accordionItemVariants = cva("border-b border-subtle");
export const accordionHeaderVariants = cva("flex");
+// `--node-size:0.875rem` sizes the leading icon (`magnitude="inherit"`) — declared here, on
+// accordion's own trigger, not on the shared `disclosureTriggerClass` (see its comment). The
+// trailing chevron is unaffected: `DisclosureIndicator`'s `magnitude="sm"` re-declares
+// `--node-size` on itself regardless of what the row provides.
export const accordionTriggerVariants = cva(
- cx(disclosureTriggerClass, "flex-1 bg-layer-transparent p-3 hover:bg-layer-transparent-hover"),
+ cx(
+ disclosureTriggerClass,
+ "flex-1 bg-layer-transparent p-3 [--node-size:0.875rem] hover:bg-layer-transparent-hover",
+ ),
);
export const accordionPanelVariants = cva(
cx(
"h-(--accordion-panel-height) overflow-hidden",
- "text-14 text-secondary",
+ "text-13 font-regular text-secondary",
"transition-[height] duration-200 ease-out",
"data-ending-style:h-0 data-starting-style:h-0",
),
@@ -35,4 +42,4 @@ export const accordionTriggerTitleVariants = cva(disclosureTriggerTitleClass);
// The padded inner content of a panel. Padding lives here, never on the
// height-animating `AccordionPanel` (padding there would jump the open/close height).
-export const accordionPanelContentVariants = cva("px-3 pb-3");
+export const accordionPanelContentVariants = cva("px-3 pt-1.5 pb-3");
diff --git a/packages/propel/src/elements/collapsible/variants.ts b/packages/propel/src/elements/collapsible/variants.ts
index 92301c7d..d2347aca 100644
--- a/packages/propel/src/elements/collapsible/variants.ts
+++ b/packages/propel/src/elements/collapsible/variants.ts
@@ -11,7 +11,11 @@ import {
// (variant/tone/magnitude) to expose. The cva pairings below hold the static
// chrome so every part is styled in one place, with no `className` at the boundary.
-export const collapsibleTriggerVariants = cva(cx(disclosureTriggerClass, "w-full"));
+// `--node-size:1rem` sizes the trailing chevron (`magnitude="inherit"`) — declared here, on
+// collapsible's own trigger, not on the shared `disclosureTriggerClass` (see its comment).
+export const collapsibleTriggerVariants = cva(
+ cx(disclosureTriggerClass, "w-full [--node-size:1rem]"),
+);
// The trigger's growing label. `flex-1` fills the row so a trailing
// `CollapsibleTriggerIndicator` sits at the inline-end edge; `min-w-0` lets a long
diff --git a/packages/propel/src/internal/disclosure-trigger.ts b/packages/propel/src/internal/disclosure-trigger.ts
index 7659e73f..66699273 100644
--- a/packages/propel/src/internal/disclosure-trigger.ts
+++ b/packages/propel/src/internal/disclosure-trigger.ts
@@ -4,13 +4,23 @@ import { cx } from "class-variance-authority";
* The disclosure trigger core shared by accordion and collapsible (rule 4a): a `group`-carrying row
* (its `DisclosureIndicator` reads the open state) with the shared type treatment, focus ring, and
* disabled dimming. Families add their own geometry (accordion's padding + hover fill,
- * collapsible's full width). `disclosureTriggerTitleClass` is the growing, wrapping label beside
- * the caret.
+ * collapsible's full width) AND their own `--node-size` (accordion's leading icon is smaller than
+ * collapsible's default) — declared once per family, never here, so there is never a second
+ * `--node-size` utility competing with it on the same element (same-specificity same-property
+ * classes cascade by stylesheet order, not by position in the class list — a family-level override
+ * appended after this shared class would silently lose to whichever `[--node-size:...]` utility
+ * Tailwind happens to emit first project-wide). `disclosureTriggerTitleClass` is the growing,
+ * wrapping label beside the caret.
*/
export const disclosureTriggerClass = cx(
- "group flex items-center gap-2 text-start [--node-size:1rem]",
+ "group flex items-center gap-2 text-start",
"text-14 font-medium text-primary",
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-accent-strong",
+ // Dim + block the cursor for BOTH disabled spellings: the native `disabled` attribute (used when a
+ // trigger is disabled directly) and Base UI's `data-disabled`/`aria-disabled` (set when the
+ // enclosing accordion/collapsible item is disabled — those keep the trigger focusable, so no
+ // native `disabled` attribute lands and `:disabled` alone would never match).
"disabled:cursor-not-allowed disabled:opacity-60",
+ "data-disabled:cursor-not-allowed data-disabled:opacity-60",
);
export const disclosureTriggerTitleClass = "min-w-0 flex-1 text-start";