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 ( +
+
+
+ setValue(next as string[])} + > + {ITEMS.map((item) => ( + + + + + {item.body} + + ))} + +
+ ); + }, +}; + /** * 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";