@rovula/ui 0.1.20 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/cjs/bundle.css +316 -43
  2. package/dist/cjs/bundle.js +675 -675
  3. package/dist/cjs/bundle.js.map +1 -1
  4. package/dist/cjs/types/components/Badge/Badge.d.ts +40 -0
  5. package/dist/cjs/types/components/Badge/Badge.stories.d.ts +295 -0
  6. package/dist/cjs/types/components/Badge/Badge.styles.d.ts +7 -0
  7. package/dist/cjs/types/components/Badge/index.d.ts +2 -0
  8. package/dist/cjs/types/components/Dropdown/Dropdown.d.ts +4 -8
  9. package/dist/cjs/types/components/Dropdown/Dropdown.stories.d.ts +1 -6
  10. package/dist/cjs/types/components/DropdownMenu/DropdownMenu.d.ts +5 -1
  11. package/dist/cjs/types/components/DropdownMenu/DropdownMenu.stories.d.ts +45 -30
  12. package/dist/cjs/types/components/Form/Form.d.ts +2 -1
  13. package/dist/cjs/types/components/Form/Form.stories.d.ts +4 -0
  14. package/dist/cjs/types/components/ScrollArea/ScrollArea.d.ts +38 -0
  15. package/dist/cjs/types/components/ScrollArea/ScrollArea.stories.d.ts +301 -0
  16. package/dist/cjs/types/index.d.ts +4 -1
  17. package/dist/cjs/types/patterns/menu/Menu.d.ts +70 -0
  18. package/dist/cjs/types/{components/Menu → patterns/menu}/Menu.stories.d.ts +17 -10
  19. package/dist/cjs/types/utils/mergeRefs.d.ts +20 -0
  20. package/dist/components/Avatar/Avatar.styles.js +2 -2
  21. package/dist/components/Badge/Badge.js +36 -0
  22. package/dist/components/Badge/Badge.stories.js +51 -0
  23. package/dist/components/Badge/Badge.styles.js +62 -0
  24. package/dist/components/Badge/index.js +2 -0
  25. package/dist/components/Dropdown/Dropdown.js +54 -163
  26. package/dist/components/Dropdown/Dropdown.stories.js +29 -0
  27. package/dist/components/DropdownMenu/DropdownMenu.js +24 -13
  28. package/dist/components/DropdownMenu/DropdownMenu.stories.js +120 -88
  29. package/dist/components/Form/Form.js +11 -4
  30. package/dist/components/Form/Form.stories.js +27 -0
  31. package/dist/components/ScrollArea/ScrollArea.js +50 -0
  32. package/dist/components/ScrollArea/ScrollArea.stories.js +56 -0
  33. package/dist/components/TextInput/TextInput.js +6 -3
  34. package/dist/esm/bundle.css +316 -43
  35. package/dist/esm/bundle.js +1545 -1545
  36. package/dist/esm/bundle.js.map +1 -1
  37. package/dist/esm/types/components/Badge/Badge.d.ts +40 -0
  38. package/dist/esm/types/components/Badge/Badge.stories.d.ts +295 -0
  39. package/dist/esm/types/components/Badge/Badge.styles.d.ts +7 -0
  40. package/dist/esm/types/components/Badge/index.d.ts +2 -0
  41. package/dist/esm/types/components/Dropdown/Dropdown.d.ts +4 -8
  42. package/dist/esm/types/components/Dropdown/Dropdown.stories.d.ts +1 -6
  43. package/dist/esm/types/components/DropdownMenu/DropdownMenu.d.ts +5 -1
  44. package/dist/esm/types/components/DropdownMenu/DropdownMenu.stories.d.ts +45 -30
  45. package/dist/esm/types/components/Form/Form.d.ts +2 -1
  46. package/dist/esm/types/components/Form/Form.stories.d.ts +4 -0
  47. package/dist/esm/types/components/ScrollArea/ScrollArea.d.ts +38 -0
  48. package/dist/esm/types/components/ScrollArea/ScrollArea.stories.d.ts +301 -0
  49. package/dist/esm/types/index.d.ts +4 -1
  50. package/dist/esm/types/patterns/menu/Menu.d.ts +70 -0
  51. package/dist/esm/types/{components/Menu → patterns/menu}/Menu.stories.d.ts +17 -10
  52. package/dist/esm/types/utils/mergeRefs.d.ts +20 -0
  53. package/dist/index.d.ts +156 -74
  54. package/dist/index.js +3 -1
  55. package/dist/patterns/menu/Menu.js +95 -0
  56. package/dist/patterns/menu/Menu.stories.js +611 -0
  57. package/dist/src/theme/global.css +485 -57
  58. package/dist/utils/mergeRefs.js +42 -0
  59. package/package.json +1 -1
  60. package/src/components/Avatar/Avatar.styles.ts +2 -2
  61. package/src/components/Badge/Badge.stories.tsx +128 -0
  62. package/src/components/Badge/Badge.styles.ts +70 -0
  63. package/src/components/Badge/Badge.tsx +103 -0
  64. package/src/components/Badge/index.ts +3 -0
  65. package/src/components/Dropdown/Dropdown.stories.tsx +170 -1
  66. package/src/components/Dropdown/Dropdown.tsx +186 -276
  67. package/src/components/DropdownMenu/DropdownMenu.stories.tsx +1375 -253
  68. package/src/components/DropdownMenu/DropdownMenu.tsx +118 -55
  69. package/src/components/Form/Form.stories.tsx +70 -0
  70. package/src/components/Form/Form.tsx +23 -0
  71. package/src/components/ScrollArea/ScrollArea.stories.tsx +229 -0
  72. package/src/components/ScrollArea/ScrollArea.tsx +72 -0
  73. package/src/components/TextInput/TextInput.tsx +6 -3
  74. package/src/index.ts +4 -1
  75. package/src/patterns/menu/Menu.stories.tsx +1100 -0
  76. package/src/patterns/menu/Menu.tsx +282 -0
  77. package/src/theme/global.css +84 -11
  78. package/src/theme/themes/xspector/baseline.css +1 -1
  79. package/src/theme/themes/xspector/components/scrollbar.css +12 -0
  80. package/src/theme/tokens/baseline.css +3 -1
  81. package/src/theme/tokens/components/badge.css +54 -0
  82. package/src/theme/tokens/components/dropdown-menu.css +16 -5
  83. package/src/theme/tokens/components/scrollbar.css +18 -0
  84. package/src/utils/mergeRefs.ts +46 -0
  85. package/dist/cjs/types/components/Menu/Menu.d.ts +0 -65
  86. package/dist/cjs/types/components/Menu/helpers.d.ts +0 -19
  87. package/dist/cjs/types/components/Menu/index.d.ts +0 -4
  88. package/dist/components/Menu/Menu.js +0 -64
  89. package/dist/components/Menu/Menu.stories.js +0 -406
  90. package/dist/components/Menu/helpers.js +0 -28
  91. package/dist/components/Menu/index.js +0 -3
  92. package/dist/esm/types/components/Menu/Menu.d.ts +0 -65
  93. package/dist/esm/types/components/Menu/helpers.d.ts +0 -19
  94. package/dist/esm/types/components/Menu/index.d.ts +0 -4
  95. package/src/components/Menu/Menu.stories.tsx +0 -586
  96. package/src/components/Menu/Menu.tsx +0 -235
  97. package/src/components/Menu/helpers.ts +0 -45
  98. package/src/components/Menu/index.ts +0 -7
  99. package/src/theme/themes/xspector/components/dropdown-menu.css +0 -28
@@ -0,0 +1,1100 @@
1
+ import React, { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react";
3
+ import { Menu, MenuItemType } from "./Menu";
4
+ import Button from "../../components/Button/Button";
5
+ import ActionButton from "../../components/ActionButton/ActionButton";
6
+ import Icon from "../../components/Icon/Icon";
7
+ import { ChevronDownIcon } from "@heroicons/react/16/solid";
8
+ import { Switch } from "../../components/Switch/Switch";
9
+
10
+ const meta = {
11
+ title: "Patterns/Menu",
12
+ component: Menu,
13
+ parameters: {
14
+ layout: "fullscreen",
15
+ },
16
+ decorators: [
17
+ (Story) => (
18
+ <div className="p-10 flex gap-8 flex-wrap bg-workspace-surface min-h-screen">
19
+ <Story />
20
+ </div>
21
+ ),
22
+ ],
23
+ } satisfies Meta<typeof Menu>;
24
+
25
+ export default meta;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Basic — default items, separator, disabled, danger
29
+ // ---------------------------------------------------------------------------
30
+ export const Basic: StoryObj<typeof Menu> = {
31
+ name: "Basic (Default Items)",
32
+ render: () => (
33
+ <div className="flex gap-8 items-start flex-wrap">
34
+ <div>
35
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
36
+ Icon button trigger
37
+ </p>
38
+ <Menu
39
+ trigger={
40
+ <ActionButton variant="icon">
41
+ <Icon type="heroicons" name="ellipsis-vertical" />
42
+ </ActionButton>
43
+ }
44
+ items={[
45
+ { type: "item", item: { value: "1", label: "Option 1" } },
46
+ { type: "item", item: { value: "2", label: "Option 2" } },
47
+ {
48
+ type: "item",
49
+ item: { value: "3", label: "Option 3 (Selected)" },
50
+ },
51
+ {
52
+ type: "item",
53
+ item: {
54
+ value: "4",
55
+ label: "Option 4 (Disabled)",
56
+ disabled: true,
57
+ },
58
+ },
59
+ { type: "separator" },
60
+ {
61
+ type: "item",
62
+ item: { value: "delete", label: "Delete", danger: true },
63
+ },
64
+ ]}
65
+ selectedValues={["3"]}
66
+ onSelect={(v) => console.log("selected:", v)}
67
+ />
68
+ </div>
69
+
70
+ <div>
71
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
72
+ Button trigger + label section
73
+ </p>
74
+ <Menu
75
+ trigger={<Button variant="outline">My Account</Button>}
76
+ items={[
77
+ { type: "label", label: "My Account" },
78
+ { type: "item", item: { value: "profile", label: "Profile" } },
79
+ { type: "item", item: { value: "settings", label: "Settings" } },
80
+ { type: "separator" },
81
+ { type: "item", item: { value: "logout", label: "Logout" } },
82
+ ]}
83
+ onSelect={(v) => console.log("selected:", v)}
84
+ />
85
+ </div>
86
+ </div>
87
+ ),
88
+ };
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // With Icons
92
+ // ---------------------------------------------------------------------------
93
+ export const WithIcons: StoryObj<typeof Menu> = {
94
+ name: "With Icons",
95
+ render: () => (
96
+ <div className="flex gap-8 items-start flex-wrap">
97
+ <div>
98
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
99
+ Items with leading icon
100
+ </p>
101
+ <Menu
102
+ trigger={
103
+ <ActionButton variant="icon">
104
+ <Icon type="heroicons" name="ellipsis-vertical" />
105
+ </ActionButton>
106
+ }
107
+ align="end"
108
+ items={[
109
+ {
110
+ type: "item",
111
+ item: {
112
+ value: "profile",
113
+ label: "Profile",
114
+ icon: <Icon type="heroicons" name="user" className="size-4" />,
115
+ },
116
+ },
117
+ {
118
+ type: "item",
119
+ item: {
120
+ value: "settings",
121
+ label: "Settings",
122
+ icon: (
123
+ <Icon
124
+ type="heroicons"
125
+ name="cog-6-tooth"
126
+ className="size-4"
127
+ />
128
+ ),
129
+ },
130
+ },
131
+ {
132
+ type: "item",
133
+ item: {
134
+ value: "billing",
135
+ label: "Billing",
136
+ icon: (
137
+ <Icon
138
+ type="heroicons"
139
+ name="credit-card"
140
+ className="size-4"
141
+ />
142
+ ),
143
+ },
144
+ },
145
+ { type: "separator" },
146
+ {
147
+ type: "item",
148
+ item: {
149
+ value: "logout",
150
+ label: "Logout",
151
+ icon: (
152
+ <Icon
153
+ type="heroicons"
154
+ name="arrow-right-on-rectangle"
155
+ className="size-4"
156
+ />
157
+ ),
158
+ danger: true,
159
+ },
160
+ },
161
+ ]}
162
+ onSelect={(v) => console.log("selected:", v)}
163
+ />
164
+ </div>
165
+
166
+ <div>
167
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
168
+ With icon + disabled
169
+ </p>
170
+ <Menu
171
+ trigger={
172
+ <Button variant="outline">
173
+ Actions <ChevronDownIcon className="size-4 ml-2" />
174
+ </Button>
175
+ }
176
+ items={[
177
+ {
178
+ type: "item",
179
+ item: {
180
+ value: "edit",
181
+ label: "Edit",
182
+ icon: (
183
+ <Icon type="heroicons" name="pencil" className="size-4" />
184
+ ),
185
+ },
186
+ },
187
+ {
188
+ type: "item",
189
+ item: {
190
+ value: "duplicate",
191
+ label: "Duplicate",
192
+ icon: (
193
+ <Icon
194
+ type="heroicons"
195
+ name="document-duplicate"
196
+ className="size-4"
197
+ />
198
+ ),
199
+ disabled: true,
200
+ },
201
+ },
202
+ {
203
+ type: "item",
204
+ item: {
205
+ value: "archive",
206
+ label: "Archive",
207
+ icon: (
208
+ <Icon
209
+ type="heroicons"
210
+ name="archive-box"
211
+ className="size-4"
212
+ />
213
+ ),
214
+ },
215
+ },
216
+ { type: "separator" },
217
+ {
218
+ type: "item",
219
+ item: {
220
+ value: "delete",
221
+ label: "Delete",
222
+ icon: <Icon type="heroicons" name="trash" className="size-4" />,
223
+ danger: true,
224
+ },
225
+ },
226
+ ]}
227
+ onSelect={(v) => console.log("selected:", v)}
228
+ />
229
+ </div>
230
+ </div>
231
+ ),
232
+ };
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // With Checkbox — multi-select toggle
236
+ // ---------------------------------------------------------------------------
237
+ export const WithCheckbox: StoryObj<typeof Menu> = {
238
+ name: "With Checkbox",
239
+ render: () => {
240
+ const [checked, setChecked] = useState({
241
+ notifications: true,
242
+ emails: false,
243
+ updates: true,
244
+ });
245
+
246
+ const items: MenuItemType[] = [
247
+ { type: "label", label: "Preferences" },
248
+ {
249
+ type: "item",
250
+ item: {
251
+ value: "notifications",
252
+ label: "Notifications",
253
+ type: "checkbox",
254
+ checked: checked.notifications,
255
+ },
256
+ },
257
+ {
258
+ type: "item",
259
+ item: {
260
+ value: "emails",
261
+ label: "Email Alerts",
262
+ type: "checkbox",
263
+ checked: checked.emails,
264
+ },
265
+ },
266
+ {
267
+ type: "item",
268
+ item: {
269
+ value: "updates",
270
+ label: "Product Updates",
271
+ type: "checkbox",
272
+ checked: checked.updates,
273
+ },
274
+ },
275
+ {
276
+ type: "item",
277
+ item: {
278
+ value: "disabled-feature",
279
+ label: "Disabled Feature",
280
+ type: "checkbox",
281
+ checked: false,
282
+ disabled: true,
283
+ },
284
+ },
285
+ ];
286
+
287
+ return (
288
+ <div className="flex gap-8 items-start">
289
+ <div>
290
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
291
+ Click to toggle (menu stays open for checkboxes)
292
+ </p>
293
+ <Menu
294
+ trigger={
295
+ <Button variant="outline">
296
+ Preferences <ChevronDownIcon className="size-4 ml-2" />
297
+ </Button>
298
+ }
299
+ items={items}
300
+ onSelect={(value) => {
301
+ setChecked((prev) => ({
302
+ ...prev,
303
+ [value]: !prev[value as keyof typeof prev],
304
+ }));
305
+ }}
306
+ />
307
+ </div>
308
+ <div className="text-sm text-text-g-contrast-medium">
309
+ <p className="font-semibold mb-1">State:</p>
310
+ <pre className="text-xs">{JSON.stringify(checked, null, 2)}</pre>
311
+ </div>
312
+ </div>
313
+ );
314
+ },
315
+ };
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // With Radio — single-select group
319
+ // ---------------------------------------------------------------------------
320
+ export const WithRadio: StoryObj<typeof Menu> = {
321
+ name: "With Radio",
322
+ render: () => {
323
+ const [theme, setTheme] = useState("light");
324
+ const [density, setDensity] = useState("comfortable");
325
+
326
+ const themeItems: MenuItemType[] = [
327
+ { type: "label", label: "Theme" },
328
+ { type: "item", item: { value: "light", label: "Light", type: "radio" } },
329
+ { type: "item", item: { value: "dark", label: "Dark", type: "radio" } },
330
+ {
331
+ type: "item",
332
+ item: { value: "system", label: "System", type: "radio" },
333
+ },
334
+ ];
335
+
336
+ const densityItems: MenuItemType[] = [
337
+ { type: "label", label: "Density" },
338
+ {
339
+ type: "item",
340
+ item: { value: "compact", label: "Compact", type: "radio" },
341
+ },
342
+ {
343
+ type: "item",
344
+ item: { value: "comfortable", label: "Comfortable", type: "radio" },
345
+ },
346
+ {
347
+ type: "item",
348
+ item: { value: "spacious", label: "Spacious", type: "radio" },
349
+ },
350
+ {
351
+ type: "item",
352
+ item: {
353
+ value: "disabled-opt",
354
+ label: "Disabled Option",
355
+ type: "radio",
356
+ disabled: true,
357
+ },
358
+ },
359
+ ];
360
+
361
+ return (
362
+ <div className="flex gap-8 items-start">
363
+ <div>
364
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
365
+ Theme selector
366
+ </p>
367
+ <Menu
368
+ trigger={
369
+ <Button variant="outline">
370
+ Theme: {theme} <ChevronDownIcon className="size-4 ml-2" />
371
+ </Button>
372
+ }
373
+ items={themeItems}
374
+ selectedValues={[theme]}
375
+ onSelect={(value) => setTheme(value)}
376
+ />
377
+ </div>
378
+
379
+ <div>
380
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
381
+ Density selector + disabled
382
+ </p>
383
+ <Menu
384
+ trigger={
385
+ <Button variant="outline">
386
+ Density: {density} <ChevronDownIcon className="size-4 ml-2" />
387
+ </Button>
388
+ }
389
+ items={densityItems}
390
+ selectedValues={[density]}
391
+ onSelect={(value) => setDensity(value)}
392
+ />
393
+ </div>
394
+ </div>
395
+ );
396
+ },
397
+ };
398
+
399
+ // ---------------------------------------------------------------------------
400
+ // Multiple Radio Groups — separated by separator
401
+ // ---------------------------------------------------------------------------
402
+ export const MultipleRadioGroups: StoryObj<typeof Menu> = {
403
+ name: "Multiple Radio Groups",
404
+ render: () => {
405
+ const [theme, setTheme] = useState("light");
406
+ const [language, setLanguage] = useState("en");
407
+
408
+ const items: MenuItemType[] = [
409
+ { type: "label", label: "Theme" },
410
+ { type: "item", item: { value: "light", label: "Light", type: "radio" } },
411
+ { type: "item", item: { value: "dark", label: "Dark", type: "radio" } },
412
+ { type: "separator" },
413
+ { type: "label", label: "Language" },
414
+ { type: "item", item: { value: "en", label: "English", type: "radio" } },
415
+ { type: "item", item: { value: "th", label: "ภาษาไทย", type: "radio" } },
416
+ { type: "item", item: { value: "ja", label: "日本語", type: "radio" } },
417
+ ];
418
+
419
+ const handleSelect = (value: string) => {
420
+ if (["light", "dark"].includes(value)) setTheme(value);
421
+ else if (["en", "th", "ja"].includes(value)) setLanguage(value);
422
+ };
423
+
424
+ return (
425
+ <div className="flex gap-8 items-start">
426
+ <div>
427
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
428
+ Two independent radio groups
429
+ </p>
430
+ <Menu
431
+ trigger={<Button variant="outline">Settings</Button>}
432
+ items={items}
433
+ selectedValues={[theme, language]}
434
+ onSelect={handleSelect}
435
+ />
436
+ </div>
437
+ <div className="text-sm text-text-g-contrast-medium">
438
+ <p className="font-semibold mb-1">State:</p>
439
+ <pre className="text-xs">
440
+ {JSON.stringify({ theme, language }, null, 2)}
441
+ </pre>
442
+ </div>
443
+ </div>
444
+ );
445
+ },
446
+ };
447
+
448
+ // ---------------------------------------------------------------------------
449
+ // Custom Items — type="custom" render prop
450
+ // ---------------------------------------------------------------------------
451
+ export const CustomItems: StoryObj<typeof Menu> = {
452
+ name: "Custom Items",
453
+ render: () => {
454
+ const items: MenuItemType[] = [
455
+ { type: "label", label: "Recent Projects" },
456
+ {
457
+ type: "custom",
458
+ render: () => (
459
+ <div className="px-4 py-3 hover:bg-[var(--dropdown-menu-hover-bg)] cursor-pointer transition-colors">
460
+ <div className="typography-subtitle4 text-text-g-contrast-high">
461
+ Project Alpha
462
+ </div>
463
+ <div className="typography-small4 text-text-g-contrast-medium">
464
+ Updated 2 hours ago
465
+ </div>
466
+ </div>
467
+ ),
468
+ },
469
+ {
470
+ type: "custom",
471
+ render: () => (
472
+ <div className="px-4 py-3 hover:bg-[var(--dropdown-menu-hover-bg)] cursor-pointer transition-colors">
473
+ <div className="typography-subtitle4 text-text-g-contrast-high">
474
+ Project Beta
475
+ </div>
476
+ <div className="typography-small4 text-text-g-contrast-medium">
477
+ Updated yesterday
478
+ </div>
479
+ </div>
480
+ ),
481
+ },
482
+ { type: "separator" },
483
+ {
484
+ type: "item",
485
+ item: {
486
+ value: "view-all",
487
+ label: "View All Projects",
488
+ icon: <Icon type="heroicons" name="folder-open" className="size-4" />,
489
+ },
490
+ },
491
+ ];
492
+
493
+ return (
494
+ <div>
495
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
496
+ Mix of custom render + regular items
497
+ </p>
498
+ <Menu
499
+ trigger={
500
+ <Button variant="outline">
501
+ Projects <ChevronDownIcon className="size-4 ml-2" />
502
+ </Button>
503
+ }
504
+ items={items}
505
+ onSelect={(v) => console.log("selected:", v)}
506
+ />
507
+ </div>
508
+ );
509
+ },
510
+ };
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // With Submenu — type="submenu" uses DropdownMenuSub natively
514
+ // ---------------------------------------------------------------------------
515
+ export const WithSubmenu: StoryObj<typeof Menu> = {
516
+ name: "With Submenu",
517
+ render: () => {
518
+ const [selected, setSelected] = useState<string | null>(null);
519
+ const [permission, setPermission] = useState("perm-view");
520
+
521
+ const items: MenuItemType[] = [
522
+ {
523
+ type: "item",
524
+ item: {
525
+ value: "new-file",
526
+ label: "New File",
527
+ icon: (
528
+ <Icon type="heroicons" name="document-plus" className="size-4" />
529
+ ),
530
+ },
531
+ },
532
+ {
533
+ type: "submenu",
534
+ label: "New From Template",
535
+ icon: (
536
+ <Icon type="heroicons" name="document-duplicate" className="size-4" />
537
+ ),
538
+ items: [
539
+ {
540
+ type: "item",
541
+ item: { value: "tpl-blank", label: "Blank Document" },
542
+ },
543
+ { type: "item", item: { value: "tpl-report", label: "Report" } },
544
+ {
545
+ type: "item",
546
+ item: { value: "tpl-presentation", label: "Presentation" },
547
+ },
548
+ { type: "separator" },
549
+ {
550
+ type: "item",
551
+ item: {
552
+ value: "tpl-browse",
553
+ label: "Browse Templates…",
554
+ icon: (
555
+ <Icon
556
+ type="heroicons"
557
+ name="arrow-top-right-on-square"
558
+ className="size-4"
559
+ />
560
+ ),
561
+ },
562
+ },
563
+ ],
564
+ },
565
+ { type: "separator" },
566
+ {
567
+ type: "submenu",
568
+ label: "Share",
569
+ icon: <Icon type="heroicons" name="share" className="size-4" />,
570
+ items: [
571
+ {
572
+ type: "item",
573
+ item: {
574
+ value: "share-link",
575
+ label: "Copy Link",
576
+ icon: <Icon type="heroicons" name="link" className="size-4" />,
577
+ },
578
+ },
579
+ {
580
+ type: "item",
581
+ item: {
582
+ value: "share-email",
583
+ label: "Send via Email",
584
+ icon: (
585
+ <Icon type="heroicons" name="envelope" className="size-4" />
586
+ ),
587
+ },
588
+ },
589
+ { type: "separator" },
590
+ {
591
+ // Nested submenu inside submenu
592
+ type: "submenu",
593
+ label: "Permissions",
594
+ items: [
595
+ {
596
+ type: "item",
597
+ item: {
598
+ value: "perm-view",
599
+ label: "Viewer",
600
+ type: "radio",
601
+ },
602
+ },
603
+ {
604
+ type: "item",
605
+ item: {
606
+ value: "perm-comment",
607
+ label: "Commenter",
608
+ type: "radio",
609
+ },
610
+ },
611
+ {
612
+ type: "item",
613
+ item: {
614
+ value: "perm-edit",
615
+ label: "Editor",
616
+ type: "radio",
617
+ },
618
+ },
619
+ ],
620
+ },
621
+ ],
622
+ },
623
+ { type: "separator" },
624
+ {
625
+ type: "item",
626
+ item: {
627
+ value: "delete",
628
+ label: "Move to Trash",
629
+ icon: <Icon type="heroicons" name="trash" className="size-4" />,
630
+ danger: true,
631
+ },
632
+ },
633
+ ];
634
+
635
+ const handleSelect = (value: string) => {
636
+ if (value.startsWith("perm-")) setPermission(value);
637
+ else setSelected(value);
638
+ };
639
+
640
+ return (
641
+ <div className="flex gap-8 items-start">
642
+ <div>
643
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
644
+ Submenu (hover to reveal) — 3 levels deep
645
+ </p>
646
+ <Menu
647
+ trigger={<Button variant="outline">File</Button>}
648
+ items={items}
649
+ selectedValues={[permission]}
650
+ onSelect={handleSelect}
651
+ />
652
+ </div>
653
+ <div className="text-sm text-text-g-contrast-medium">
654
+ <p className="font-semibold mb-1">State:</p>
655
+ <pre className="text-xs">
656
+ {JSON.stringify({ selected, permission }, null, 2)}
657
+ </pre>
658
+ </div>
659
+ </div>
660
+ );
661
+ },
662
+ };
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // Complex — mixed types, multi-section
666
+ // ---------------------------------------------------------------------------
667
+ export const ComplexMenu: StoryObj<typeof Menu> = {
668
+ name: "Complex (Mixed Types)",
669
+ render: () => {
670
+ const [preferences, setPreferences] = useState({
671
+ notifications: true,
672
+ emails: false,
673
+ });
674
+ const [theme, setTheme] = useState("light");
675
+
676
+ const items: MenuItemType[] = [
677
+ { type: "label", label: "My Account" },
678
+ {
679
+ type: "item",
680
+ item: {
681
+ value: "profile",
682
+ label: "Profile",
683
+ icon: <Icon type="heroicons" name="user" className="size-4" />,
684
+ onClick: () => console.log("Go to profile"),
685
+ },
686
+ },
687
+ {
688
+ type: "item",
689
+ item: {
690
+ value: "billing",
691
+ label: "Billing",
692
+ icon: <Icon type="heroicons" name="credit-card" className="size-4" />,
693
+ onClick: () => console.log("Go to billing"),
694
+ },
695
+ },
696
+ { type: "separator" },
697
+ { type: "label", label: "Notifications" },
698
+ {
699
+ type: "item",
700
+ item: {
701
+ value: "notifications",
702
+ label: "Push Notifications",
703
+ type: "checkbox",
704
+ checked: preferences.notifications,
705
+ },
706
+ },
707
+ {
708
+ type: "item",
709
+ item: {
710
+ value: "emails",
711
+ label: "Email Notifications",
712
+ type: "checkbox",
713
+ checked: preferences.emails,
714
+ },
715
+ },
716
+ { type: "separator" },
717
+ { type: "label", label: "Theme" },
718
+ {
719
+ type: "item",
720
+ item: { value: "light", label: "Light Mode", type: "radio" },
721
+ },
722
+ {
723
+ type: "item",
724
+ item: { value: "dark", label: "Dark Mode", type: "radio" },
725
+ },
726
+ {
727
+ type: "item",
728
+ item: { value: "system", label: "System", type: "radio" },
729
+ },
730
+ { type: "separator" },
731
+ {
732
+ type: "item",
733
+ item: {
734
+ value: "logout",
735
+ label: "Logout",
736
+ icon: (
737
+ <Icon
738
+ type="heroicons"
739
+ name="arrow-right-on-rectangle"
740
+ className="size-4"
741
+ />
742
+ ),
743
+ danger: true,
744
+ onClick: () => console.log("Logout"),
745
+ },
746
+ },
747
+ ];
748
+
749
+ const handleSelect = (value: string, item: { type?: string }) => {
750
+ if (item.type === "checkbox") {
751
+ const key = value as keyof typeof preferences;
752
+ if (key in preferences)
753
+ setPreferences((prev) => ({ ...prev, [key]: !prev[key] }));
754
+ } else if (item.type === "radio") {
755
+ setTheme(value);
756
+ }
757
+ };
758
+
759
+ return (
760
+ <div className="flex gap-8 items-start">
761
+ <div>
762
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
763
+ Account menu — all item types combined
764
+ </p>
765
+ <Menu
766
+ trigger={
767
+ <Button variant="outline">
768
+ Account <ChevronDownIcon className="size-4 ml-2" />
769
+ </Button>
770
+ }
771
+ items={items}
772
+ selectedValues={[theme]}
773
+ onSelect={handleSelect}
774
+ />
775
+ </div>
776
+ <div className="text-sm text-text-g-contrast-medium">
777
+ <p className="font-semibold mb-1">State:</p>
778
+ <pre className="text-xs">
779
+ {JSON.stringify({ preferences, theme }, null, 2)}
780
+ </pre>
781
+ </div>
782
+ </div>
783
+ );
784
+ },
785
+ };
786
+
787
+ // ---------------------------------------------------------------------------
788
+ // Multi-Select via Checkbox
789
+ // ---------------------------------------------------------------------------
790
+ export const MultiSelectPattern: StoryObj<typeof Menu> = {
791
+ name: "Multi-Select Pattern",
792
+ render: () => {
793
+ const [selectedValues, setSelectedValues] = useState<string[]>(["react"]);
794
+
795
+ const options = [
796
+ { value: "react", label: "React" },
797
+ { value: "vue", label: "Vue" },
798
+ { value: "angular", label: "Angular" },
799
+ { value: "svelte", label: "Svelte" },
800
+ { value: "solid", label: "Solid" },
801
+ ];
802
+
803
+ const items: MenuItemType[] = options.map((opt) => ({
804
+ type: "item",
805
+ item: {
806
+ value: opt.value,
807
+ label: opt.label,
808
+ type: "checkbox" as const,
809
+ checked: selectedValues.includes(opt.value),
810
+ },
811
+ }));
812
+
813
+ const displayText =
814
+ selectedValues.length > 0
815
+ ? selectedValues
816
+ .map((v) => options.find((o) => o.value === v)?.label)
817
+ .join(", ")
818
+ : "Select frameworks…";
819
+
820
+ return (
821
+ <div className="flex gap-8 items-start">
822
+ <Menu
823
+ trigger={
824
+ <Button variant="outline" className="w-64 justify-between">
825
+ <span className="truncate">{displayText}</span>
826
+ <ChevronDownIcon className="size-4 ml-2 shrink-0" />
827
+ </Button>
828
+ }
829
+ items={items}
830
+ onSelect={(value) => {
831
+ setSelectedValues((prev) =>
832
+ prev.includes(value)
833
+ ? prev.filter((v) => v !== value)
834
+ : [...prev, value],
835
+ );
836
+ }}
837
+ />
838
+ <div className="text-sm text-text-g-contrast-medium">
839
+ <p className="font-semibold mb-1">Selected:</p>
840
+ <pre className="text-xs">
841
+ {JSON.stringify(selectedValues, null, 2)}
842
+ </pre>
843
+ </div>
844
+ </div>
845
+ );
846
+ },
847
+ };
848
+
849
+ // ---------------------------------------------------------------------------
850
+ // Figma: "Change Status" — header slot + section labels + custom badge items
851
+ // ---------------------------------------------------------------------------
852
+ export const ChangeStatus: StoryObj<typeof Menu> = {
853
+ name: "Figma: Change Status",
854
+ render: () => {
855
+ const [status, setStatus] = useState("todo");
856
+ const [open, setOpen] = useState(false);
857
+
858
+ const statusItems: MenuItemType[] = [
859
+ { type: "label", label: "To do" },
860
+ {
861
+ type: "item",
862
+ item: { value: "todo", label: "To do", type: "radio" },
863
+ },
864
+ { type: "separator" },
865
+ { type: "label", label: "In progress" },
866
+ {
867
+ type: "item",
868
+ item: {
869
+ value: "in-progress",
870
+ label: "In Progress",
871
+ type: "radio",
872
+ icon: (
873
+ <span className="inline-flex items-center px-3 py-0.5 rounded-lg text-xs font-medium bg-yellow-400/10 text-yellow-400">
874
+ In Progress
875
+ </span>
876
+ ),
877
+ },
878
+ },
879
+ {
880
+ type: "item",
881
+ item: {
882
+ value: "ready-review",
883
+ label: "Ready to review",
884
+ type: "radio",
885
+ icon: (
886
+ <span className="inline-flex items-center px-3 py-0.5 rounded-lg text-xs font-medium bg-blue-400/10 text-blue-400">
887
+ Ready to review
888
+ </span>
889
+ ),
890
+ },
891
+ },
892
+ {
893
+ type: "item",
894
+ item: {
895
+ value: "in-review",
896
+ label: "In review",
897
+ type: "radio",
898
+ icon: (
899
+ <span className="inline-flex items-center px-3 py-0.5 rounded-lg text-xs font-medium bg-red-400/10 text-red-400">
900
+ In review
901
+ </span>
902
+ ),
903
+ },
904
+ },
905
+ { type: "separator" },
906
+ { type: "label", label: "Completed" },
907
+ {
908
+ type: "item",
909
+ item: {
910
+ value: "completed",
911
+ label: "Completed",
912
+ type: "radio",
913
+ icon: (
914
+ <span className="inline-flex items-center px-3 py-0.5 rounded-lg text-xs font-medium bg-green-400/10 text-green-400">
915
+ Completed
916
+ </span>
917
+ ),
918
+ },
919
+ },
920
+ ];
921
+
922
+ return (
923
+ <div className="flex gap-8 items-start">
924
+ <div>
925
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
926
+ With header (title + settings + close)
927
+ </p>
928
+ <Menu
929
+ trigger={<Button variant="outline">Change Status</Button>}
930
+ open={open}
931
+ onOpenChange={setOpen}
932
+ header={
933
+ <div className="flex items-center justify-between px-6 py-3">
934
+ <span className="typography-subtitle4 text-text-g-contrast-high">
935
+ Status
936
+ </span>
937
+ <div className="flex items-center gap-1">
938
+ <ActionButton variant="icon" size="sm">
939
+ <Icon
940
+ type="heroicons"
941
+ name="cog6-tooth"
942
+ className="size-4"
943
+ />
944
+ </ActionButton>
945
+ {/* X ปิด menu ได้ผ่าน onOpenChange */}
946
+ <ActionButton
947
+ variant="icon"
948
+ size="sm"
949
+ onClick={() => setOpen(false)}
950
+ >
951
+ <Icon type="heroicons" name="xmark" className="size-4" />
952
+ </ActionButton>
953
+ </div>
954
+ </div>
955
+ }
956
+ items={statusItems}
957
+ selectedValues={[status]}
958
+ onSelect={(v) => setStatus(v)}
959
+ />
960
+ </div>
961
+
962
+ <div>
963
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
964
+ No header (simple list)
965
+ </p>
966
+ <Menu
967
+ trigger={<Button variant="outline">Status</Button>}
968
+ items={statusItems}
969
+ selectedValues={[status]}
970
+ onSelect={(v) => setStatus(v)}
971
+ />
972
+ </div>
973
+
974
+ <div className="text-sm text-text-g-contrast-medium">
975
+ <p className="font-semibold mb-1">Selected:</p>
976
+ <pre className="text-xs">{status}</pre>
977
+ </div>
978
+ </div>
979
+ );
980
+ },
981
+ };
982
+
983
+ // ---------------------------------------------------------------------------
984
+ // Figma: "Manage Column" — header with actions + toggle rows + drag handle
985
+ // ---------------------------------------------------------------------------
986
+ export const ManageColumn: StoryObj<typeof Menu> = {
987
+ name: "Figma: Manage Column",
988
+ render: () => {
989
+ const [open, setOpen] = useState(false);
990
+ const [columns, setColumns] = useState([
991
+ { id: "name", label: "Name", visible: true },
992
+ { id: "status", label: "Status", visible: true },
993
+ { id: "assignee", label: "Assignee", visible: false },
994
+ { id: "due-date", label: "Due date", visible: true },
995
+ { id: "priority", label: "Priority", visible: false },
996
+ ]);
997
+
998
+ const toggleColumn = (id: string) =>
999
+ setColumns((prev) =>
1000
+ prev.map((col) =>
1001
+ col.id === id ? { ...col, visible: !col.visible } : col,
1002
+ ),
1003
+ );
1004
+
1005
+ const showAll = () =>
1006
+ setColumns((prev) => prev.map((col) => ({ ...col, visible: true })));
1007
+
1008
+ const hideAll = () =>
1009
+ setColumns((prev) => prev.map((col) => ({ ...col, visible: false })));
1010
+
1011
+ const items: MenuItemType[] = columns.map((col) => ({
1012
+ type: "custom",
1013
+ render: () => (
1014
+ <div
1015
+ key={col.id}
1016
+ className="flex items-center gap-3 px-4 py-3 hover:bg-[var(--dropdown-menu-hover-bg)] cursor-default transition-colors"
1017
+ >
1018
+ <Icon
1019
+ type="heroicons"
1020
+ name="bars-2"
1021
+ className="size-4 text-text-g-contrast-medium shrink-0 cursor-grab"
1022
+ />
1023
+ <Switch
1024
+ checked={col.visible}
1025
+ onCheckedChange={() => toggleColumn(col.id)}
1026
+ />
1027
+ <span className="typography-subtitle4 text-text-g-contrast-high flex-1">
1028
+ {col.label}
1029
+ </span>
1030
+ </div>
1031
+ ),
1032
+ }));
1033
+
1034
+ return (
1035
+ <div className="flex gap-8 items-start">
1036
+ <div>
1037
+ <p className="typography-small4 text-text-g-contrast-medium mb-2">
1038
+ Manage Column panel
1039
+ </p>
1040
+ <Menu
1041
+ trigger={
1042
+ <Button variant="outline">
1043
+ <Icon
1044
+ type="heroicons"
1045
+ name="view-columns"
1046
+ className="size-4 mr-2"
1047
+ />
1048
+ Manage Columns
1049
+ </Button>
1050
+ }
1051
+ open={open}
1052
+ onOpenChange={setOpen}
1053
+ header={
1054
+ <div className="flex items-center justify-between px-4 py-3">
1055
+ <span className="typography-subtitle4 text-text-g-contrast-high">
1056
+ Manage column
1057
+ </span>
1058
+ <div className="flex items-center gap-2">
1059
+ <button
1060
+ className="typography-small4 text-text-g-contrast-medium hover:text-text-g-contrast-high transition-colors"
1061
+ onClick={hideAll}
1062
+ >
1063
+ Hide all
1064
+ </button>
1065
+ <button
1066
+ className="typography-small4 text-[var(--dropdown-menu-checkbox-checked-bg)] hover:opacity-80 transition-opacity"
1067
+ onClick={showAll}
1068
+ >
1069
+ Show all
1070
+ </button>
1071
+ {/* Done ปิด menu ได้ผ่าน onOpenChange */}
1072
+ <Button
1073
+ size="sm"
1074
+ variant="outline"
1075
+ onClick={() => setOpen(false)}
1076
+ >
1077
+ Done
1078
+ </Button>
1079
+ </div>
1080
+ </div>
1081
+ }
1082
+ items={items}
1083
+ contentClassName="w-80"
1084
+ />
1085
+ </div>
1086
+
1087
+ <div className="text-sm text-text-g-contrast-medium">
1088
+ <p className="font-semibold mb-1">Columns:</p>
1089
+ <pre className="text-xs">
1090
+ {JSON.stringify(
1091
+ columns.map((c) => ({ [c.label]: c.visible })),
1092
+ null,
1093
+ 2,
1094
+ )}
1095
+ </pre>
1096
+ </div>
1097
+ </div>
1098
+ );
1099
+ },
1100
+ };