Skip to content

Commit a8d1182

Browse files
authored
Merge pull request #90 from schemavaults/claude/determined-feynman-e7pssm
0.82.0 - Add SplitButton component
2 parents e5f5844 + 568abd0 commit a8d1182

6 files changed

Lines changed: 551 additions & 1 deletion

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@schemavaults/ui",
3-
"version": "0.81.0",
3+
"version": "0.82.0",
44
"private": false,
55
"license": "UNLICENSED",
66
"description": "React.js UI components for SchemaVaults frontend applications",

src/components/ui/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,3 +320,6 @@ export type * from "./terminal-frame";
320320

321321
export * from "./inline-edit";
322322
export type * from "./inline-edit";
323+
324+
export * from "./split-button";
325+
export type * from "./split-button";
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { fn } from "storybook/test";
3+
import {
4+
ArrowUpToLine,
5+
Copy,
6+
FileText,
7+
GitBranch,
8+
Save,
9+
Send,
10+
Trash2,
11+
} from "lucide-react";
12+
import type { ReactElement } from "react";
13+
14+
import {
15+
DropdownMenuItem,
16+
DropdownMenuLabel,
17+
DropdownMenuSeparator,
18+
DropdownMenuShortcut,
19+
} from "@/components/ui/dropdown-menu";
20+
import {
21+
SplitButton,
22+
SplitButtonAction,
23+
SplitButtonContent,
24+
SplitButtonTrigger,
25+
} from "./split-button";
26+
import {
27+
splitButtonSizeIds,
28+
splitButtonVariantIds,
29+
} from "./split-button-variants";
30+
31+
const meta = {
32+
title: "Components/SplitButton",
33+
component: SplitButton,
34+
parameters: {
35+
layout: "centered",
36+
},
37+
tags: ["autodocs"],
38+
argTypes: {
39+
variant: {
40+
options: splitButtonVariantIds,
41+
control: { type: "radio" },
42+
},
43+
size: {
44+
options: splitButtonSizeIds,
45+
control: { type: "radio" },
46+
},
47+
disabled: { control: { type: "boolean" } },
48+
},
49+
args: {
50+
variant: "default",
51+
size: "default",
52+
disabled: false,
53+
},
54+
} satisfies Meta<typeof SplitButton>;
55+
56+
export default meta;
57+
type Story = StoryObj<typeof meta>;
58+
59+
/**
60+
* The canonical SplitButton pattern: a primary action (Save) paired with a
61+
* dropdown of related, secondary actions.
62+
*/
63+
export const Default: Story = {
64+
render: (args): ReactElement => (
65+
<SplitButton {...args}>
66+
<SplitButtonAction onClick={fn()}>
67+
<Save aria-hidden="true" className="mr-2 h-4 w-4" />
68+
Save
69+
</SplitButtonAction>
70+
<SplitButtonTrigger />
71+
<SplitButtonContent>
72+
<DropdownMenuLabel>Save options</DropdownMenuLabel>
73+
<DropdownMenuItem onClick={fn()}>
74+
<FileText aria-hidden="true" className="mr-2 h-4 w-4" />
75+
Save as draft
76+
<DropdownMenuShortcut>&#8984;D</DropdownMenuShortcut>
77+
</DropdownMenuItem>
78+
<DropdownMenuItem onClick={fn()}>
79+
<Copy aria-hidden="true" className="mr-2 h-4 w-4" />
80+
Save as template
81+
</DropdownMenuItem>
82+
<DropdownMenuSeparator />
83+
<DropdownMenuItem onClick={fn()}>
84+
<GitBranch aria-hidden="true" className="mr-2 h-4 w-4" />
85+
Save and open in new branch
86+
</DropdownMenuItem>
87+
</SplitButtonContent>
88+
</SplitButton>
89+
),
90+
};
91+
92+
export const Destructive: Story = {
93+
args: { variant: "destructive" },
94+
render: (args): ReactElement => (
95+
<SplitButton {...args}>
96+
<SplitButtonAction onClick={fn()}>
97+
<Trash2 aria-hidden="true" className="mr-2 h-4 w-4" />
98+
Delete vault
99+
</SplitButtonAction>
100+
<SplitButtonTrigger aria-label="More delete options" />
101+
<SplitButtonContent>
102+
<DropdownMenuItem onClick={fn()}>
103+
Delete and archive
104+
</DropdownMenuItem>
105+
<DropdownMenuItem onClick={fn()}>
106+
Delete and export contents
107+
</DropdownMenuItem>
108+
<DropdownMenuSeparator />
109+
<DropdownMenuItem onClick={fn()} className="text-destructive">
110+
Permanently delete now
111+
</DropdownMenuItem>
112+
</SplitButtonContent>
113+
</SplitButton>
114+
),
115+
};
116+
117+
export const Outline: Story = {
118+
args: { variant: "outline" },
119+
render: (args): ReactElement => (
120+
<SplitButton {...args}>
121+
<SplitButtonAction onClick={fn()}>
122+
<ArrowUpToLine aria-hidden="true" className="mr-2 h-4 w-4" />
123+
Publish
124+
</SplitButtonAction>
125+
<SplitButtonTrigger />
126+
<SplitButtonContent>
127+
<DropdownMenuItem onClick={fn()}>Publish to staging</DropdownMenuItem>
128+
<DropdownMenuItem onClick={fn()}>Publish to preview</DropdownMenuItem>
129+
<DropdownMenuItem onClick={fn()}>Schedule publish&hellip;</DropdownMenuItem>
130+
</SplitButtonContent>
131+
</SplitButton>
132+
),
133+
};
134+
135+
export const Secondary: Story = {
136+
args: { variant: "secondary" },
137+
render: (args): ReactElement => (
138+
<SplitButton {...args}>
139+
<SplitButtonAction onClick={fn()}>Run query</SplitButtonAction>
140+
<SplitButtonTrigger />
141+
<SplitButtonContent>
142+
<DropdownMenuItem onClick={fn()}>Run with sample data</DropdownMenuItem>
143+
<DropdownMenuItem onClick={fn()}>Run with cache off</DropdownMenuItem>
144+
<DropdownMenuItem onClick={fn()}>Explain query plan</DropdownMenuItem>
145+
</SplitButtonContent>
146+
</SplitButton>
147+
),
148+
};
149+
150+
export const Ghost: Story = {
151+
args: { variant: "ghost" },
152+
render: (args): ReactElement => (
153+
<SplitButton {...args}>
154+
<SplitButtonAction onClick={fn()}>Share</SplitButtonAction>
155+
<SplitButtonTrigger />
156+
<SplitButtonContent>
157+
<DropdownMenuItem onClick={fn()}>Copy shareable link</DropdownMenuItem>
158+
<DropdownMenuItem onClick={fn()}>Invite by email&hellip;</DropdownMenuItem>
159+
<DropdownMenuItem onClick={fn()}>Generate embed code</DropdownMenuItem>
160+
</SplitButtonContent>
161+
</SplitButton>
162+
),
163+
};
164+
165+
export const Disabled: Story = {
166+
args: { disabled: true },
167+
render: (args): ReactElement => (
168+
<SplitButton {...args}>
169+
<SplitButtonAction>Deploy</SplitButtonAction>
170+
<SplitButtonTrigger />
171+
<SplitButtonContent>
172+
<DropdownMenuItem>Deploy to staging</DropdownMenuItem>
173+
<DropdownMenuItem>Deploy to production</DropdownMenuItem>
174+
</SplitButtonContent>
175+
</SplitButton>
176+
),
177+
};
178+
179+
/**
180+
* All variants stacked so a reviewer can eyeball colour and divider treatment
181+
* side-by-side.
182+
*/
183+
export const AllVariants: Story = {
184+
render: (): ReactElement => (
185+
<div className="flex flex-col gap-4 p-4">
186+
{splitButtonVariantIds.map((variant) => (
187+
<div key={variant} className="flex items-center gap-4">
188+
<span className="w-24 text-sm text-muted-foreground capitalize">
189+
{variant}
190+
</span>
191+
<SplitButton variant={variant}>
192+
<SplitButtonAction onClick={fn()}>
193+
<Send aria-hidden="true" className="mr-2 h-4 w-4" />
194+
Send
195+
</SplitButtonAction>
196+
<SplitButtonTrigger />
197+
<SplitButtonContent>
198+
<DropdownMenuItem onClick={fn()}>Send now</DropdownMenuItem>
199+
<DropdownMenuItem onClick={fn()}>Schedule&hellip;</DropdownMenuItem>
200+
<DropdownMenuItem onClick={fn()}>Save draft</DropdownMenuItem>
201+
</SplitButtonContent>
202+
</SplitButton>
203+
</div>
204+
))}
205+
</div>
206+
),
207+
};
208+
209+
/**
210+
* All sizes side-by-side so vertical rhythm and chevron scaling are easy to
211+
* check against the standard Button sizes.
212+
*/
213+
export const AllSizes: Story = {
214+
render: (): ReactElement => (
215+
<div className="flex items-end gap-4 p-4">
216+
{splitButtonSizeIds.map((size) => (
217+
<div key={size} className="flex flex-col items-center gap-2">
218+
<span className="text-xs uppercase text-muted-foreground">
219+
{size}
220+
</span>
221+
<SplitButton size={size} variant="default">
222+
<SplitButtonAction onClick={fn()}>Save</SplitButtonAction>
223+
<SplitButtonTrigger />
224+
<SplitButtonContent>
225+
<DropdownMenuItem onClick={fn()}>Save as draft</DropdownMenuItem>
226+
<DropdownMenuItem onClick={fn()}>
227+
Save as template
228+
</DropdownMenuItem>
229+
</SplitButtonContent>
230+
</SplitButton>
231+
</div>
232+
))}
233+
</div>
234+
),
235+
};
236+
237+
/**
238+
* A real-world example: schema migration UI where the primary action is
239+
* "Apply migration" and the dropdown offers safer or more granular variants.
240+
*/
241+
export const SchemaMigrationExample: Story = {
242+
render: (): ReactElement => (
243+
<div className="flex w-[420px] flex-col gap-3 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm">
244+
<div>
245+
<div className="text-sm font-semibold">users → users_v2</div>
246+
<div className="text-xs text-muted-foreground">
247+
3 columns added, 1 renamed, 0 dropped
248+
</div>
249+
</div>
250+
<div className="flex items-center justify-end gap-2">
251+
<SplitButton variant="default">
252+
<SplitButtonAction onClick={fn()}>Apply migration</SplitButtonAction>
253+
<SplitButtonTrigger aria-label="More migration options" />
254+
<SplitButtonContent>
255+
<DropdownMenuItem onClick={fn()}>Dry-run only</DropdownMenuItem>
256+
<DropdownMenuItem onClick={fn()}>Apply and snapshot</DropdownMenuItem>
257+
<DropdownMenuSeparator />
258+
<DropdownMenuItem onClick={fn()}>
259+
Export migration SQL
260+
</DropdownMenuItem>
261+
</SplitButtonContent>
262+
</SplitButton>
263+
</div>
264+
</div>
265+
),
266+
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export {
2+
SplitButton,
3+
SplitButtonAction,
4+
SplitButtonTrigger,
5+
SplitButtonContent,
6+
splitButtonSizeIds,
7+
splitButtonVariantIds,
8+
} from "./split-button";
9+
export type * from "./split-button";
10+
export type * from "./split-button-variants";
11+
export { SplitButton as default } from "./split-button";
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export const splitButtonVariantIds = [
2+
"default",
3+
"destructive",
4+
"outline",
5+
"secondary",
6+
"ghost",
7+
] as const satisfies readonly string[];
8+
export type SplitButtonVariant = (typeof splitButtonVariantIds)[number];
9+
10+
export const splitButtonSizeIds = [
11+
"sm",
12+
"default",
13+
"lg",
14+
] as const satisfies readonly string[];
15+
export type SplitButtonSize = (typeof splitButtonSizeIds)[number];

0 commit comments

Comments
 (0)