Skip to content

Commit f240734

Browse files
author
Brian Casel
committed
update
1 parent 21bf2a9 commit f240734

5 files changed

Lines changed: 283 additions & 0 deletions

File tree

app/frontend/components/design-system/DesignSystem.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { FootersSection } from "@/components/design-system/sections/structure/Fo
1515
import { BaseStylesSection } from "@/components/design-system/sections/base-styles/BaseStylesSection";
1616
import { IconographySection } from "@/components/design-system/sections/elements/IconographySection";
1717
import { ButtonsSection } from "@/components/design-system/sections/elements/ButtonsSection";
18+
import { ButtonDropdownSection } from "@/components/design-system/sections/elements/ButtonDropdownSection";
1819
import { FormsSection } from "@/components/design-system/sections/elements/FormsSection";
1920
import { BadgesSection } from "@/components/design-system/sections/elements/BadgesSection";
2021
import { ToggleButtonsSection } from "@/components/design-system/sections/elements/ToggleButtonsSection";
@@ -113,6 +114,7 @@ export function DesignSystem() {
113114
<FootersSection />
114115
<IconographySection />
115116
<ButtonsSection />
117+
<ButtonDropdownSection />
116118
<FormsSection />
117119
<BadgesSection />
118120
<ToggleButtonsSection />

app/frontend/components/design-system/SidebarNav.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ const NAV: NavGroup[] = [
2929
items: [
3030
{ id: "iconography", label: "Iconography" },
3131
{ id: "buttons", label: "Buttons" },
32+
{ id: "button-dropdown", label: "Button dropdown" },
3233
{ id: "forms", label: "Forms" },
3334
{ id: "badges", label: "Badges" },
3435
{ id: "toggle-buttons", label: "Toggle buttons" },
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { SectionShell } from "@/components/design-system/SectionShell";
2+
import { ButtonDropdown } from "@/components/ui/button-dropdown";
3+
import {
4+
DropdownMenuItem,
5+
DropdownMenuSeparator,
6+
} from "@/components/ui/dropdown-menu";
7+
import { ArrowRight, Copy, Pause, Trash2 } from "lucide-react";
8+
9+
const code = `import { ButtonDropdown } from "@/components/ui/button-dropdown";
10+
import {
11+
DropdownMenuItem,
12+
DropdownMenuSeparator,
13+
} from "@/components/ui/dropdown-menu";
14+
import { ArrowRight, Pause, Trash2 } from "lucide-react";
15+
16+
<ButtonDropdown
17+
action={<>Greenlight <ArrowRight className="h-4 w-4" /></>}
18+
onClick={() => doPrimary()}
19+
>
20+
<DropdownMenuItem onSelect={() => doPark()}>
21+
<Pause /> Park
22+
</DropdownMenuItem>
23+
<DropdownMenuSeparator />
24+
<DropdownMenuItem destructive onSelect={() => doDiscard()}>
25+
<Trash2 /> Discard
26+
</DropdownMenuItem>
27+
</ButtonDropdown>
28+
29+
<ButtonDropdown
30+
variant="secondary"
31+
action="Duplicate"
32+
onClick={() => doDuplicate()}
33+
>
34+
<DropdownMenuItem onSelect={() => doDuplicateAs()}>
35+
Duplicate as draft
36+
</DropdownMenuItem>
37+
<DropdownMenuItem onSelect={() => doExport()}>
38+
Export
39+
</DropdownMenuItem>
40+
</ButtonDropdown>`;
41+
42+
export function ButtonDropdownSection() {
43+
return (
44+
<SectionShell
45+
id="button-dropdown"
46+
title="Button dropdown"
47+
description={
48+
<>
49+
A split button — a primary action on the left and a chevron end-cap
50+
on the right that opens a dropdown of related secondary actions. The
51+
two halves render as one shape with a thin currentColor-tinted
52+
divider between them, so it works on any variant (and on custom
53+
backgrounds set via <code>className</code>). The menu uses the same
54+
dropdown primitive documented below.
55+
</>
56+
}
57+
whenToUse={
58+
<ul>
59+
<li>
60+
One action is the obvious next step but a small set of related
61+
secondary actions belong in the same spot.
62+
</li>
63+
<li>
64+
Page-header action boxes where the primary action lives next to
65+
"park / discard / reset"-style alternates.
66+
</li>
67+
</ul>
68+
}
69+
whenNotToUse={
70+
<ul>
71+
<li>
72+
The actions don't share intent — use separate buttons (or a plain{" "}
73+
<code>DropdownMenu</code> if none of them is the obvious primary).
74+
</li>
75+
<li>
76+
There's only the main action — use a plain <code>Button</code>.
77+
</li>
78+
<li>
79+
The menu is a list of navigation destinations rather than
80+
secondary actions — use a plain <code>DropdownMenu</code>.
81+
</li>
82+
</ul>
83+
}
84+
preview={
85+
<div className="flex flex-wrap items-center gap-4">
86+
<ButtonDropdown
87+
action={
88+
<>
89+
Greenlight <ArrowRight className="h-4 w-4" />
90+
</>
91+
}
92+
onClick={() => {}}
93+
>
94+
<DropdownMenuItem onSelect={() => {}}>
95+
<Pause /> Park
96+
</DropdownMenuItem>
97+
<DropdownMenuSeparator />
98+
<DropdownMenuItem destructive onSelect={() => {}}>
99+
<Trash2 /> Discard
100+
</DropdownMenuItem>
101+
</ButtonDropdown>
102+
103+
<ButtonDropdown
104+
variant="secondary"
105+
action={
106+
<>
107+
<Copy className="h-4 w-4" /> Duplicate
108+
</>
109+
}
110+
onClick={() => {}}
111+
>
112+
<DropdownMenuItem onSelect={() => {}}>
113+
Duplicate as draft
114+
</DropdownMenuItem>
115+
<DropdownMenuItem onSelect={() => {}}>Export</DropdownMenuItem>
116+
</ButtonDropdown>
117+
118+
<ButtonDropdown
119+
variant="danger"
120+
action="Delete"
121+
onClick={() => {}}
122+
>
123+
<DropdownMenuItem onSelect={() => {}}>
124+
Move to trash
125+
</DropdownMenuItem>
126+
<DropdownMenuItem onSelect={() => {}}>Archive</DropdownMenuItem>
127+
</ButtonDropdown>
128+
</div>
129+
}
130+
code={code}
131+
options={
132+
<ul className="list-disc pl-5">
133+
<li>
134+
<code>action</code>: content of the left (primary) button. String
135+
or any ReactNode.
136+
</li>
137+
<li>
138+
<code>onClick</code>: handler for the primary action. The toggle
139+
(chevron) opens the menu and is independent.
140+
</li>
141+
<li>
142+
<code>variant</code>: any <code>Button</code> variant —{" "}
143+
<code>primary</code> (default), <code>secondary</code>,{" "}
144+
<code>ghost</code>, <code>soft</code>, <code>danger</code>,{" "}
145+
<code>link</code>.
146+
</li>
147+
<li>
148+
<code>size</code>: <code>sm</code> | <code>md</code> (default) |{" "}
149+
<code>lg</code>.
150+
</li>
151+
<li>
152+
<code>className</code> / <code>toggleClassName</code>: extra
153+
classes for each half. Useful for custom-colored buttons (e.g.
154+
stage-color buttons in this app). <code>toggleClassName</code>{" "}
155+
defaults to mirror <code>className</code>.
156+
</li>
157+
<li>
158+
<code>menuAlign</code>: <code>start | center | end</code> (default{" "}
159+
<code>end</code>) — forwarded to <code>DropdownMenuContent</code>.
160+
</li>
161+
<li>
162+
<code>toggleAriaLabel</code>: aria-label for the chevron half.
163+
Default <code>"More actions"</code>.
164+
</li>
165+
<li>
166+
<strong>Children</strong>: pass{" "}
167+
<code>DropdownMenuItem</code> /{" "}
168+
<code>DropdownMenuSeparator</code> children — the standard
169+
dropdown-menu primitives.
170+
</li>
171+
</ul>
172+
}
173+
/>
174+
);
175+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// bm-design-system: button-dropdown primitive (split button + menu)
2+
import * as React from "react";
3+
import { ChevronDown } from "lucide-react";
4+
import { Button, buttonVariants, type ButtonProps } from "@/components/ui/button";
5+
import {
6+
DropdownMenu,
7+
DropdownMenuContent,
8+
DropdownMenuTrigger,
9+
} from "@/components/ui/dropdown-menu";
10+
import { cn } from "@/lib/utils";
11+
12+
type Variant = NonNullable<ButtonProps["variant"]>;
13+
type Size = NonNullable<ButtonProps["size"]>;
14+
15+
export interface ButtonDropdownProps
16+
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
17+
/** Visible label / content of the main (left) action button. */
18+
action: React.ReactNode;
19+
/** Dropdown menu items — pass DropdownMenuItem / DropdownMenuSeparator children. */
20+
children: React.ReactNode;
21+
variant?: Variant;
22+
size?: Exclude<Size, "icon">;
23+
/** Optional override for the toggle's aria-label. */
24+
toggleAriaLabel?: string;
25+
/** Forwarded to DropdownMenuContent. */
26+
menuAlign?: "start" | "center" | "end";
27+
/** Extra classes for the action half (e.g. PHASE_BUTTON_CLASSES.pitch). */
28+
className?: string;
29+
/** Extra classes for the toggle half. Defaults to mirror `className`. */
30+
toggleClassName?: string;
31+
/** Extra classes for the outer wrapper. */
32+
wrapperClassName?: string;
33+
}
34+
35+
const ButtonDropdown = React.forwardRef<HTMLButtonElement, ButtonDropdownProps>(
36+
(
37+
{
38+
action,
39+
children,
40+
variant = "primary",
41+
size = "md",
42+
toggleAriaLabel = "More actions",
43+
menuAlign = "end",
44+
className,
45+
toggleClassName,
46+
wrapperClassName,
47+
disabled,
48+
...actionProps
49+
},
50+
ref,
51+
) => {
52+
const toggleClasses = toggleClassName ?? className;
53+
return (
54+
<div className={cn("btn-dropdown", wrapperClassName)}>
55+
<Button
56+
ref={ref}
57+
variant={variant}
58+
size={size}
59+
disabled={disabled}
60+
className={cn("btn-dropdown-action", className)}
61+
{...actionProps}
62+
>
63+
{action}
64+
</Button>
65+
<DropdownMenu>
66+
<DropdownMenuTrigger asChild>
67+
<button
68+
type="button"
69+
aria-label={toggleAriaLabel}
70+
disabled={disabled}
71+
className={cn(
72+
buttonVariants({ variant, size }),
73+
"btn-dropdown-toggle",
74+
toggleClasses,
75+
)}
76+
>
77+
<ChevronDown className="h-4 w-4" />
78+
</button>
79+
</DropdownMenuTrigger>
80+
<DropdownMenuContent align={menuAlign}>{children}</DropdownMenuContent>
81+
</DropdownMenu>
82+
</div>
83+
);
84+
},
85+
);
86+
ButtonDropdown.displayName = "ButtonDropdown";
87+
88+
export { ButtonDropdown };

app/frontend/styles/design-system.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,23 @@
166166
.btn-danger { @apply bg-danger text-page hover:bg-danger/90; }
167167
.btn-link { @apply text-accent underline-offset-2 hover:underline p-0 h-auto; }
168168

169+
/* Button-dropdown (split button: primary action on the left, chevron end-cap
170+
on the right that opens a menu of secondary actions). The two halves are
171+
visually joined into one shape — the action half is rounded-left only and
172+
the toggle half is rounded-right only, with a thin currentColor-tinted
173+
divider between them so a single divider rule works across every variant
174+
and custom-colored use. */
175+
.btn-dropdown { @apply inline-flex; }
176+
.btn-dropdown-action { @apply rounded-r-none; }
177+
.btn-dropdown-toggle {
178+
@apply rounded-l-none px-2;
179+
border-left: 1px solid color-mix(in oklab, currentColor, transparent 75%);
180+
}
181+
/* Secondary variant already has a 1px border on every side; collapse the
182+
shared edge so the divider doesn't read as a doubled hairline. */
183+
.btn-dropdown-action.btn-secondary { border-right-width: 0; }
184+
.btn-dropdown-toggle.btn-secondary { border-left-color: var(--color-hairline); }
185+
169186
/* Form fields (input, select, textarea) */
170187
.form-control {
171188
@apply flex h-10 w-full rounded-md border border-hairline bg-page px-3 py-2 text-sm text-ink-body;

0 commit comments

Comments
 (0)