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
131 changes: 131 additions & 0 deletions packages/propel/src/components/accordion/accordion.stories.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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) => (
<Accordion {...args} defaultValue={["what"]}>
{ITEMS.map((item) => (
<AccordionItem key={item.value} value={item.value} disabled={item.value === "pricing"}>
<AccordionHeader>
<AccordionTrigger label={item.label} />
</AccordionHeader>
<AccordionPanel>{item.body}</AccordionPanel>
</AccordionItem>
))}
</Accordion>
),
};

/**
* 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<string[]>(["what"]);
return (
<div className="flex flex-col gap-3">
<div className="flex gap-2">
<Button
prominence="secondary"
tone="neutral"
magnitude="sm"
sizing="hug"
label="Expand all"
onClick={() => setValue(ITEMS.map((item) => item.value))}
/>
<Button
prominence="secondary"
tone="neutral"
magnitude="sm"
sizing="hug"
label="Collapse all"
onClick={() => setValue([])}
/>
</div>
<Accordion
{...args}
multiple
value={value}
onValueChange={(next) => setValue(next as string[])}
>
{ITEMS.map((item) => (
<AccordionItem key={item.value} value={item.value}>
<AccordionHeader>
<AccordionTrigger label={item.label} />
</AccordionHeader>
<AccordionPanel>{item.body}</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</div>
);
},
};

/**
* Behavior test: clicking a collapsed trigger expands its panel and flips `aria-expanded` to true
* (a `region` appears); clicking again collapses it.
Expand Down Expand Up @@ -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");
},
};
186 changes: 185 additions & 1 deletion packages/propel/src/elements/accordion/accordion.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -168,7 +174,10 @@ export const States: Story = {
</AccordionItem>
<AccordionItem>
<AccordionHeader>
<AccordionTrigger disabled aria-expanded={false}>
{/* 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. */}
<AccordionTrigger data-disabled="" aria-disabled aria-expanded={false}>
<AccordionTriggerTitle>Disabled</AccordionTriggerTitle>
<DisclosureIndicator motion="disclose" tint="secondary" magnitude="sm">
<ChevronDown />
Expand Down Expand Up @@ -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: () => (
<div dir="rtl">
<Accordion>
<AccordionItem>
<AccordionHeader>
<AccordionTrigger aria-expanded data-panel-open="">
<Icon tint="secondary">
<CircleHelp />
</Icon>
<AccordionTriggerTitle>Open — caret points down</AccordionTriggerTitle>
<DisclosureIndicator motion="disclose" tint="secondary" magnitude="sm">
<ChevronDown />
</DisclosureIndicator>
</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>
<AccordionPanelContent>
The panel content, laid out right-to-left.
</AccordionPanelContent>
</AccordionPanel>
</AccordionItem>
<AccordionItem>
<AccordionHeader>
<AccordionTrigger aria-expanded={false}>
<Icon tint="secondary">
<CircleHelp />
</Icon>
<AccordionTriggerTitle>Closed — caret points inline-end</AccordionTriggerTitle>
<DisclosureIndicator motion="disclose" tint="secondary" magnitude="sm">
<ChevronDown />
</DisclosureIndicator>
</AccordionTrigger>
</AccordionHeader>
</AccordionItem>
</Accordion>
</div>
),
};

/**
* 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: () => (
<>
<Accordion>
<AccordionItem>
<AccordionHeader>
<AccordionTrigger aria-expanded={false}>
<AccordionTriggerTitle>LTR closed</AccordionTriggerTitle>
<DisclosureIndicator motion="disclose" tint="secondary" magnitude="sm">
<ChevronDown />
</DisclosureIndicator>
</AccordionTrigger>
</AccordionHeader>
</AccordionItem>
</Accordion>
<div dir="rtl">
<Accordion>
<AccordionItem>
<AccordionHeader>
<AccordionTrigger aria-expanded={false}>
<AccordionTriggerTitle>RTL closed</AccordionTriggerTitle>
<DisclosureIndicator motion="disclose" tint="secondary" magnitude="sm">
<ChevronDown />
</DisclosureIndicator>
</AccordionTrigger>
</AccordionHeader>
</AccordionItem>
</Accordion>
</div>
</>
),
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: () => (
<Accordion>
<AccordionItem>
<AccordionHeader>
<AccordionTrigger aria-expanded data-panel-open="">
<Icon tint="secondary">
<CircleHelp />
</Icon>
<AccordionTriggerTitle>{LONG_TITLE}</AccordionTriggerTitle>
<DisclosureIndicator motion="disclose" tint="secondary" magnitude="sm">
<ChevronDown />
</DisclosureIndicator>
</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>
<AccordionPanelContent>
The panel body sits below the wrapped multi-line title.
</AccordionPanelContent>
</AccordionPanel>
</AccordionItem>
</Accordion>
),
};

/**
* 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: () => (
<Accordion>
<AccordionItem>
<AccordionHeader>
<AccordionTrigger aria-expanded={false}>
<AccordionTriggerTitle>Short</AccordionTriggerTitle>
<DisclosureIndicator motion="disclose" tint="secondary" magnitude="sm">
<ChevronDown />
</DisclosureIndicator>
</AccordionTrigger>
</AccordionHeader>
</AccordionItem>
<AccordionItem>
<AccordionHeader>
<AccordionTrigger aria-expanded={false}>
<AccordionTriggerTitle>{LONG_TITLE}</AccordionTriggerTitle>
<DisclosureIndicator motion="disclose" tint="secondary" magnitude="sm">
<ChevronDown />
</DisclosureIndicator>
</AccordionTrigger>
</AccordionHeader>
</AccordionItem>
</Accordion>
),
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);
},
};
13 changes: 10 additions & 3 deletions packages/propel/src/elements/accordion/variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
Expand All @@ -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");
6 changes: 5 additions & 1 deletion packages/propel/src/elements/collapsible/variants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading