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
5 changes: 5 additions & 0 deletions .changeset/collapsible-trailing-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@makeplane/propel": minor
---

Add a `trailing` slot to `Collapsible` for a header-end control that sits beside the trigger instead of inside it. Atomic assemblies wrap the trigger in `CollapsibleHeader` and put the control in `CollapsibleTriggerTrailing`.
23 changes: 23 additions & 0 deletions apps/docs/src/demos/collapsible/trailing-action.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Collapsible } from "@makeplane/propel/components/collapsible";
import { Icon } from "@makeplane/propel/components/icon";
import { IconButton } from "@makeplane/propel/components/icon-button";
import { Plus } from "lucide-react";

export default function TrailingActionDemo() {
return (
<Collapsible
trigger="Attachments 1"
defaultOpen
trailing={
<IconButton
variant="ghost"
size="sm"
aria-label="Add attachment"
icon={<Icon icon={Plus} />}
/>
}
>
Screen Recording 2026-08-28 at 7.20.21PM.mov
</Collapsible>
);
}
64 changes: 43 additions & 21 deletions apps/docs/src/pages/components/collapsible.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import WithoutIconDemo from "~/demos/collapsible/without-icon.tsx";
import withoutIconSource from "~/demos/collapsible/without-icon.tsx?raw";
import WithoutIndicatorDemo from "~/demos/collapsible/without-indicator.tsx";
import withoutIndicatorSource from "~/demos/collapsible/without-indicator.tsx?raw";
import TrailingActionDemo from "~/demos/collapsible/trailing-action.tsx";
import trailingActionSource from "~/demos/collapsible/trailing-action.tsx?raw";
import DisabledDemo from "~/demos/collapsible/disabled.tsx";
import disabledSource from "~/demos/collapsible/disabled.tsx?raw";
import KeepMountedDemo from "~/demos/collapsible/keep-mounted.tsx";
Expand Down Expand Up @@ -52,11 +54,12 @@ import { Collapsible } from "@makeplane/propel/components/collapsible";

Pass `trigger` for the header label and `children` for the panel body. Pass a public `<Icon>` into
`icon` for the leading 16px glyph (Figma’s resting art includes it). The rotating chevron is on by
default (`indicator` defaults to `true`). Drive open state with `defaultOpen` (uncontrolled) or
`open` + `onOpenChange` (controlled). Pass `keepMounted` to leave the panel in the DOM while
closed, or `hiddenUntilFound` so closed content also stays findable via the browser’s
find-in-page — `hiddenUntilFound` alone already keeps the panel mounted, so the two are not meant
to be combined (Base UI Panel props).
default (`indicator` defaults to `true`). Pass `trailing` for a header-end control (typically an
`IconButton`) — it renders as a sibling of the trigger, not inside it. Drive open state with
`defaultOpen` (uncontrolled) or `open` + `onOpenChange` (controlled). Pass `keepMounted` to leave
the panel in the DOM while closed, or `hiddenUntilFound` so closed content also stays findable via
the browser’s find-in-page — `hiddenUntilFound` alone already keeps the panel mounted, so the two
are not meant to be combined (Base UI Panel props).

<CodeBlock code={basicSource} lang="tsx" />

Expand All @@ -72,40 +75,46 @@ to be combined (Base UI Panel props).
import { Collapsible as BaseCollapsible } from "@base-ui/react/collapsible";
import {
Collapsible,
CollapsibleHeader,
CollapsibleTrigger,
CollapsibleTriggerTitle,
CollapsibleTriggerTrailing,
CollapsiblePanel,
CollapsiblePanelContent,
} from "@makeplane/propel/components/collapsible";
// Styled Root frame (`w-full`) — separate from the ready-made `Collapsible` above (name collision).
import { Collapsible as CollapsibleFrame } from "@makeplane/propel/elements/collapsible";

// Ready-made — grafts Base UI Root → CollapsibleFrame for you
<Collapsible trigger="…" icon={…}>
<Collapsible trigger="…" icon={…} trailing={…}>
</Collapsible>

// Atomic parts — graft Base UI yourself
<BaseCollapsible.Root render={<CollapsibleFrame />}>
<BaseCollapsible.Trigger render={<CollapsibleTrigger />}>
{icon}
<CollapsibleTriggerTitle>…</CollapsibleTriggerTitle>
{/* Rotating chevron — the shared caret is internal; supply your own */}
</BaseCollapsible.Trigger>
<CollapsibleHeader>
<BaseCollapsible.Trigger render={<CollapsibleTrigger />}>
{icon}
<CollapsibleTriggerTitle>…</CollapsibleTriggerTitle>
{/* Rotating chevron — the shared caret is internal; supply your own */}
</BaseCollapsible.Trigger>
<CollapsibleTriggerTrailing>{/* IconButton, never nested in the trigger */}</CollapsibleTriggerTrailing>
</CollapsibleHeader>
<BaseCollapsible.Panel render={<CollapsiblePanel />}>
<CollapsiblePanelContent>…</CollapsiblePanelContent>
</BaseCollapsible.Panel>
</BaseCollapsible.Root>
```

The Root frame is full width so centered hosts don’t shrink-wrap when closed. The trigger packs
`[icon?][title][chevron]` as a left-aligned cluster. Long titles wrap instead of staying
single-line (Figma’s short sample uses nowrap). Panel padding and body type live on
`CollapsiblePanelContent` (`px-3 pt-1.5 pb-3` + body-xs secondary) so the height animation on
`CollapsiblePanel` measures cleanly and List sections (children directly in the panel) do not
inherit Collapsible prose tokens. The rotating chevron itself is an internal shared primitive (not
exported) — an atomic assembly supplies its own, e.g. a `lucide-react` `ChevronDown` rotated off
`data-panel-open`.
The Root frame is full width so centered hosts don’t shrink-wrap when closed. The header is a flex
row: the trigger packs `[icon?][title][chevron]` as a left-aligned cluster and grows so leftover
space stays the toggle hit target; `CollapsibleTriggerTrailing` pins a sibling control at the
inline-end. Long titles wrap instead of staying single-line (Figma’s short sample uses nowrap).
Panel padding and body type live on `CollapsiblePanelContent` (`px-3 pt-1.5 pb-3` + body-xs
secondary) so the height animation on `CollapsiblePanel` measures cleanly and List sections
(children directly in the panel) do not inherit Collapsible prose tokens. The rotating chevron
itself is an internal shared primitive (not exported) — an atomic assembly supplies its own, e.g. a
`lucide-react` `ChevronDown` rotated off `data-panel-open`.

</ComponentSection>

Expand Down Expand Up @@ -143,6 +152,16 @@ Pass `open` and `onOpenChange` to drive the disclosure from your own state inste
<WithoutIndicatorDemo client:visible />
</ComponentExample>

### Trailing action

Pass `trailing` for a header-end control. It is a sibling of the trigger — never nest a button
inside the trigger. Clicking it does not toggle the panel. Root `disabled` does not disable this
control; disable the control itself if both should refuse input.

<ComponentExample code={trailingActionSource}>
<TrailingActionDemo client:visible />
</ComponentExample>

### Disabled

Pass Root `disabled` to dim the trigger and block open/close.
Expand Down Expand Up @@ -170,8 +189,11 @@ Pass `keepMounted` to leave the panel in the DOM while closed. `hiddenUntilFound

- The trigger is a native `<button>` with Base UI's `aria-expanded` / `aria-controls`.
- The chevron is decorative (`aria-hidden`); the accessible name comes from the trigger label.
- Disabled state dims the row via Root `disabled` (`data-disabled` / `aria-disabled`) and swaps the
cursor; the trigger stays focusable but will not toggle.
- A `trailing` control is a sibling of the trigger, never nested inside it (nested buttons are
invalid). It is its own tab stop and does not toggle the panel.
- Disabled state dims the trigger and blocks open/close via Root `disabled` (`data-disabled` /
`aria-disabled`) and swaps the cursor; the trigger stays focusable but will not toggle. Trailing
controls are not disabled by Root `disabled`.
- Focus-visible draws the shared accent ring on the trigger.

</ComponentSection>
Expand Down
78 changes: 75 additions & 3 deletions packages/propel/src/components/collapsible/collapsible.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Activity } from "lucide-react";
import { Activity, Plus } from "lucide-react";
import * as React from "react";
import { expect } from "storybook/test";

import { Button } from "../button";
import { Icon } from "../icon";
import { IconButton } from "../icon-button";
import { Collapsible } from "./index";

// Components-tier story: the ready-made `<Collapsible trigger=… icon? indicator?>body</Collapsible>`
// Components-tier story: the ready-made `<Collapsible trigger=… icon? trailing? indicator?>body</Collapsible>`
// wrapper that wires the trigger and panel for the 90% case. The elements-tier story assembles the
// atomic parts. `indicator` defaults to `true`; set `false` to omit the chevron. Figma's resting
// art defaults the leading icon on, so Default args include it.
// art defaults the leading icon on, so Default args include it. `trailing` is a header-end sibling
// of the trigger (never nested inside it).
const meta = {
title: "Components/Collapsible",
component: Collapsible,
Expand Down Expand Up @@ -58,6 +60,76 @@ export const WithoutIndicator: Story = {
args: { indicator: false },
};

/**
* Header-end control as a sibling of the trigger. Clicking it does not toggle the panel; leftover
* space on the trigger row still does. Root `disabled` does not disable this control.
*/
export const TrailingAction: Story = {
args: {
icon: undefined,
trigger: "Attachments 1",
defaultOpen: true,
trailing: (
<IconButton
variant="ghost"
size="sm"
aria-label="Add attachment"
icon={<Icon icon={Plus} />}
/>
),
children: "Screen Recording 2026-08-28 at 7.20.21PM.mov",
},
};

/**
* Behavior twin of `TrailingAction`: the trailing control is a separate button (not nested in the
* trigger), does not toggle `aria-expanded`, and the trigger still toggles.
*/
export const TrailingActionInteraction: Story = {
...TrailingAction,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas, userEvent }) => {
const trigger = canvas.getByRole("button", { name: "Attachments 1" });
const add = canvas.getByRole("button", { name: "Add attachment" });
await expect(trigger.contains(add)).toBe(false);
await expect(trigger).toHaveAttribute("aria-expanded", "true");
await userEvent.click(add);
await expect(trigger).toHaveAttribute("aria-expanded", "true");
await userEvent.click(trigger);
await expect(trigger).toHaveAttribute("aria-expanded", "false");
},
};

/**
* Root `disabled` blocks the trigger but leaves the trailing control enabled — disable that control
* yourself if both should refuse input.
*/
export const TrailingActionDisabledInteraction: Story = {
args: {
disabled: true,
icon: undefined,
trigger: "Attachments 1",
trailing: (
<IconButton
variant="ghost"
size="sm"
aria-label="Add attachment"
icon={<Icon icon={Plus} />}
/>
),
},
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas, userEvent }) => {
const trigger = canvas.getByRole("button", { name: "Attachments 1" });
const add = canvas.getByRole("button", { name: "Add attachment" });
await expect(trigger).toHaveAttribute("aria-disabled", "true");
await expect(add).not.toHaveAttribute("aria-disabled");
await expect(trigger).toHaveAttribute("aria-expanded", "false");
await userEvent.click(add);
await expect(trigger).toHaveAttribute("aria-expanded", "false");
},
};

/**
* Panel stays in the DOM while closed (`keepMounted`) — or use `hiddenUntilFound` instead so closed
* content also stays findable via the browser’s find-in-page (Base UI Panel props; either alone
Expand Down
36 changes: 25 additions & 11 deletions packages/propel/src/components/collapsible/collapsible.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import type * as React from "react";

import {
Collapsible as CollapsibleElement,
CollapsibleHeader,
CollapsiblePanel,
CollapsiblePanelContent,
CollapsibleTrigger,
CollapsibleTriggerTitle,
CollapsibleTriggerTrailing,
} from "../../elements/collapsible";
import { DisclosureIndicator } from "../../internal/disclosure-indicator";

Expand All @@ -27,6 +29,13 @@ export type CollapsibleProps = Omit<
icon?: React.ReactNode;
/** The button content that opens and closes the panel. */
trigger: React.ReactNode;
/**
* Control rendered at the header's inline-end, as a sibling of the trigger — never inside it (the
* trigger is a button; nested buttons are invalid). Typical content is an `IconButton`.
* Independent of open state: clicking it does not toggle the panel. Root `disabled` does not
* disable this control.
*/
trailing?: React.ReactNode;
/** The collapsible content region. */
children: React.ReactNode;
/**
Expand All @@ -44,12 +53,14 @@ export type CollapsibleProps = Omit<
/**
* The ready-made collapsible: a single show/hide disclosure that wires the trigger and panel for
* the 90% case. Pass `trigger` for the toggle button label and `children` for the body; optional
* `icon` for a leading glyph. Forward `defaultOpen` (uncontrolled) or `open` + `onOpenChange`
* (controlled) to drive it. Set `indicator={false}` to omit the rotating chevron from the trigger.
* `icon` for a leading glyph and `trailing` for a header-end control (sibling of the trigger).
* Forward `defaultOpen` (uncontrolled) or `open` + `onOpenChange` (controlled) to drive it. Set
* `indicator={false}` to omit the rotating chevron from the trigger.
*/
export function Collapsible({
icon,
trigger,
trailing,
children,
indicator = true,
keepMounted,
Expand All @@ -61,15 +72,18 @@ export function Collapsible({
// Consumer `render` threads into the styled frame (behavior outer, styled part as the render
// target, rule 1a) rather than replacing it.
<BaseCollapsible.Root {...props} render={<CollapsibleElement render={render} />}>
<BaseCollapsible.Trigger render={<CollapsibleTrigger />}>
{icon}
<CollapsibleTriggerTitle>{trigger}</CollapsibleTriggerTitle>
{indicator ? (
<DisclosureIndicator motion="disclose" tint="secondary">
<ChevronDown />
</DisclosureIndicator>
) : null}
</BaseCollapsible.Trigger>
<CollapsibleHeader>
<BaseCollapsible.Trigger render={<CollapsibleTrigger />}>
{icon}
<CollapsibleTriggerTitle>{trigger}</CollapsibleTriggerTitle>
{indicator ? (
<DisclosureIndicator motion="disclose" tint="secondary">
<ChevronDown />
</DisclosureIndicator>
) : null}
</BaseCollapsible.Trigger>
{trailing ? <CollapsibleTriggerTrailing>{trailing}</CollapsibleTriggerTrailing> : null}
</CollapsibleHeader>
<BaseCollapsible.Panel
keepMounted={keepMounted}
hiddenUntilFound={hiddenUntilFound}
Expand Down
4 changes: 4 additions & 0 deletions packages/propel/src/components/collapsible/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export * from "./collapsible";
// Re-export the atomic structural parts so a custom collapsible is importable from this convenience.
export {
CollapsibleHeader,
type CollapsibleHeaderProps,
CollapsiblePanel,
type CollapsiblePanelProps,
CollapsiblePanelContent,
Expand All @@ -9,4 +11,6 @@ export {
type CollapsibleTriggerProps,
CollapsibleTriggerTitle,
type CollapsibleTriggerTitleProps,
CollapsibleTriggerTrailing,
type CollapsibleTriggerTrailingProps,
} from "../../elements/collapsible";
18 changes: 18 additions & 0 deletions packages/propel/src/elements/collapsible/collapsible-header.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";

import { collapsibleHeaderVariants } from "./variants";

export type CollapsibleHeaderProps = Omit<useRender.ComponentProps<"div">, "className" | "style">;

/**
* The header row around the disclosure trigger. Holds the trigger (which packs the label cluster)
* and an optional `CollapsibleTriggerTrailing` sibling at the inline-end. Layout-only — Base UI
* Collapsible has no Header part, so this is not a behavior graft.
*/
export function CollapsibleHeader({ render, ...props }: CollapsibleHeaderProps) {
const defaultProps: useRender.ElementProps<"div"> = {
className: collapsibleHeaderVariants(),
};
return useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) });
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ export type CollapsibleTriggerTitleProps = Omit<

/**
* The trigger's label. Packed beside an optional leading icon and the disclosure caret (Figma
* clusters `[icon?][title][chevron]` — the title does not grow to push the caret trail-end). Long
* labels wrap (`min-w-0`) rather than Figma's sample `whitespace-nowrap`, matching Accordion.
* clusters `[icon?][title][chevron]` — the title does not grow to push the caret trail-end). A
* header trailing action sits outside the trigger, as a sibling. Long labels wrap (`min-w-0`)
* rather than Figma's sample `whitespace-nowrap`, matching Accordion.
*/
export function CollapsibleTriggerTitle({ render, ...props }: CollapsibleTriggerTitleProps) {
const defaultProps: useRender.ElementProps<"span"> = {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";

import { collapsibleTriggerTrailingVariants } from "./variants";

export type CollapsibleTriggerTrailingProps = Omit<
useRender.ComponentProps<"div">,
"className" | "style"
>;

/**
* Inline-end slot for a header action, sibling of `CollapsibleTrigger` — never nested inside it
* (the trigger is a button). Typical content is an `IconButton`. End padding matches the panel
* inset so the control lines up with the body.
*/
export function CollapsibleTriggerTrailing({ render, ...props }: CollapsibleTriggerTrailingProps) {
const defaultProps: useRender.ElementProps<"div"> = {
className: collapsibleTriggerTrailingVariants(),
};
return useRender({ defaultTagName: "div", render, props: mergeProps(defaultProps, props) });
}
Loading
Loading