svelte-comp 1.3.5 → 1.3.6

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 (46) hide show
  1. package/LICENSE.md +21 -21
  2. package/README.md +101 -101
  3. package/dist/App.svelte +1046 -1046
  4. package/dist/Container.svelte +59 -59
  5. package/dist/app.css +234 -234
  6. package/dist/app.d.ts +10 -10
  7. package/dist/lib/Accordion.svelte +155 -155
  8. package/dist/lib/Badge.svelte +44 -44
  9. package/dist/lib/Button.svelte +185 -185
  10. package/dist/lib/Calendar.svelte +384 -384
  11. package/dist/lib/Card.svelte +103 -103
  12. package/dist/lib/Carousel.svelte +293 -293
  13. package/dist/lib/CheckBox.svelte +210 -210
  14. package/dist/lib/CodeView.svelte +308 -308
  15. package/dist/lib/ColorPicker.svelte +159 -159
  16. package/dist/lib/ContextMenu.svelte +328 -328
  17. package/dist/lib/DatePicker.svelte +246 -246
  18. package/dist/lib/Dialog.svelte +233 -233
  19. package/dist/lib/Field.svelte +299 -299
  20. package/dist/lib/FilePicker.svelte +295 -295
  21. package/dist/lib/Form.svelte +438 -438
  22. package/dist/lib/Hamburger.svelte +217 -217
  23. package/dist/lib/InstallPWA.svelte +94 -94
  24. package/dist/lib/Menu.svelte +623 -623
  25. package/dist/lib/NoticeBase.svelte +140 -140
  26. package/dist/lib/PaginatedCard.svelte +73 -73
  27. package/dist/lib/Pagination.svelte +119 -119
  28. package/dist/lib/PrimaryColorSelect.svelte +111 -111
  29. package/dist/lib/ProgressBar.svelte +141 -141
  30. package/dist/lib/ProgressCircle.svelte +190 -190
  31. package/dist/lib/Radio.svelte +189 -189
  32. package/dist/lib/SearchInput.svelte +104 -104
  33. package/dist/lib/Select.svelte +524 -524
  34. package/dist/lib/Slider.svelte +253 -253
  35. package/dist/lib/Splitter.svelte +159 -159
  36. package/dist/lib/Switch.svelte +168 -168
  37. package/dist/lib/Table.svelte +299 -299
  38. package/dist/lib/Tabs.svelte +213 -213
  39. package/dist/lib/ThemeToggle.svelte +128 -128
  40. package/dist/lib/TimePicker.svelte +312 -312
  41. package/dist/lib/TimePickerNew.svelte +634 -634
  42. package/dist/lib/Toast.svelte +123 -123
  43. package/dist/lib/Tooltip.svelte +110 -110
  44. package/dist/lib/Topbar.svelte +112 -112
  45. package/dist/styles.css +234 -234
  46. package/package.json +52 -52
@@ -1,623 +1,623 @@
1
- <!-- src/lib/Menu.svelte -->
2
- <script lang="ts">
3
- /**
4
- * @component Menu
5
- * @description A dropdown menu bar component with hover and click interactions.
6
- *
7
- * @prop menus {MenuItem[]} - Menu definitions with actions
8
- * @default []
9
- *
10
- * @prop onSelect {(menu: string, action: MenuAction) => void} - Fired when an action is chosen
11
- * @default () => {}
12
- *
13
- * @prop class {string} - Extra classes applied to the menu bar
14
- * @default ""
15
- *
16
- * @prop sz {SizeKey} - Size preset for spacing and text
17
- * @options xs|sm|md|lg|xl
18
- * @default sm
19
- *
20
- * @note Fully keyboard-safe for focus and mouse interactions.
21
- * @note Submenus open on hover when another menu is already open.
22
- * @note Actions that match size keys (`xs`, `sm`, `md`, `lg`, `xl`) are automatically highlighted to reflect the current UI size.
23
- * @note Uses the same CSS variable architecture as Tabs for consistent look across components.
24
- * @note No slots; fully controlled via the `menus` structure and `onSelect`.
25
- */
26
- import type { SizeKey, MenuItem, MenuAction } from "./types";
27
- import { TEXT } from "./types";
28
- import { cx } from "../utils";
29
-
30
- type Props = {
31
- menus?: MenuItem[];
32
- onSelect?: (menu: string, action: MenuAction) => void;
33
- class?: string;
34
- sz?: SizeKey;
35
- };
36
-
37
- let {
38
- menus = [],
39
- onSelect = () => {},
40
- class: externalClass = "",
41
- sz = "sm",
42
- }: Props = $props();
43
-
44
- let open = $state<string>("");
45
- let openSub = $state<string>("");
46
- let activeIndex = $state(-1);
47
- let activeSubIndex = $state(-1);
48
-
49
- // Refs for focus control
50
- let triggerRefs = $state<Record<string, HTMLButtonElement>>({});
51
- let menuRefs = $state<Record<string, HTMLDivElement>>({});
52
- let itemRefs = $state<Record<string, HTMLButtonElement>>({});
53
- let subItemRefs = $state<Record<string, HTMLButtonElement>>({});
54
-
55
- // Positioning
56
- let menuTop = $state(0);
57
- let menuLeft = $state(0);
58
-
59
- let subMenuRefs = $state<Record<string, HTMLDivElement>>({});
60
- let subMenuTop = $state(0);
61
- let subMenuLeft = $state(0);
62
-
63
- const sizes: Record<SizeKey, string> = {
64
- xs: "h-[calc(var(--spacing-md)+var(--spacing-sm)+var(--spacing-xs))] px-[calc(var(--spacing-sm)+var(--spacing-xs))]",
65
- sm: "h-[var(--spacing-xl)] px-[calc(var(--spacing-sm)+var(--spacing-xs))]",
66
- md: "h-[calc(var(--spacing-xl)+var(--spacing-xs))] px-[var(--spacing-md)]",
67
- lg: "h-[calc(var(--spacing-xl)+var(--spacing-sm))] px-[var(--spacing-md)]",
68
- xl: "h-[calc(var(--spacing-xl)+var(--spacing-sm)+var(--spacing-xs))] px-[calc(var(--spacing-md)+var(--spacing-xs))]",
69
- };
70
-
71
- const navBase =
72
- "flex items-stretch pl-[var(--spacing-sm)] gap-[var(--spacing-xs)] border-b relative z-10 bg-[var(--color-bg-surface)] text-[var(--color-text-default)] border-[var(--border-color-default)]";
73
-
74
- const subMenuGutter = 8;
75
-
76
- const topButtonBase =
77
- "px-[var(--spacing-md)] rounded-[var(--radius-sm)] leading-none transition-colors outline-none [@media(pointer:coarse)]:min-h-11 focus-visible:shadow-[inset_0_0_0_2px_var(--border-color-focus)]";
78
-
79
- const topButtonActive =
80
- "bg-[var(--color-bg-muted)] text-[var(--color-text-default)]";
81
- const topButtonIdle =
82
- "hover:bg-[var(--color-bg-muted)] text-[var(--color-text-default)]";
83
-
84
- const menuStyle = $derived(
85
- `position:fixed; top:${menuTop}px; left:${menuLeft}px; width:max-content; max-width:calc(100vw - 16px);`
86
- );
87
- const subMenuStyle = $derived(
88
- `position:fixed; top:${subMenuTop}px; left:${subMenuLeft}px; width:max-content; max-width:calc(100vw - 16px);`
89
- );
90
-
91
- const textCls = $derived(TEXT[sz]);
92
- const hotkeyColCls = "flex items-center shrink-0";
93
-
94
- const navCls = $derived(cx(navBase, sizes[sz], textCls, externalClass));
95
- const topBtnBaseCls = $derived(cx(topButtonBase, sizes[sz], textCls));
96
-
97
- function actionText(a: MenuAction) {
98
- if (typeof a === "string") return a;
99
- return a.label;
100
- }
101
-
102
- function actionId(a: MenuAction) {
103
- if (typeof a === "string") return a;
104
- return a.id ?? a.label ?? "";
105
- }
106
-
107
- function actionShortcut(a: MenuAction) {
108
- return typeof a === "string" ? "" : (a.shortcut ?? "");
109
- }
110
-
111
- function isSeparator(
112
- a: MenuAction
113
- ): a is Exclude<MenuAction, string> & { type: "separator" } {
114
- return typeof a !== "string" && "type" in a && a.type === "separator";
115
- }
116
-
117
- function hasSubmenu(
118
- a: MenuAction
119
- ): a is Exclude<MenuAction, string> & { submenu: MenuAction[] } {
120
- return (
121
- typeof a !== "string" && Array.isArray(a.submenu) && a.submenu.length > 0
122
- );
123
- }
124
-
125
- function actionKey(a: MenuAction, idx: number) {
126
- const id = actionId(a);
127
- return id || `__action-${idx}`;
128
- }
129
-
130
- function select(menu: string, action: MenuAction) {
131
- closeMenus();
132
- onSelect(menu, action);
133
- }
134
-
135
- function closeMenus() {
136
- open = "";
137
- openSub = "";
138
- activeIndex = -1;
139
- activeSubIndex = -1;
140
- }
141
-
142
- // Positioning dropdown
143
- function updateMenuPosition(
144
- triggerEl: HTMLElement,
145
- menuEl?: HTMLElement | null
146
- ) {
147
- const rect = triggerEl.getBoundingClientRect();
148
- const menuWidth = Math.min(
149
- menuEl?.getBoundingClientRect().width ?? rect.width,
150
- window.innerWidth - 16
151
- );
152
- const spaceRight = window.innerWidth - rect.left;
153
- const spaceLeft = rect.right;
154
- const alignRight = spaceRight < menuWidth && spaceLeft > spaceRight;
155
- const viewportLeft = window.scrollX;
156
- const viewportRight = window.scrollX + window.innerWidth;
157
-
158
- menuTop = rect.bottom + window.scrollY;
159
- const targetLeft = alignRight
160
- ? rect.right + window.scrollX - menuWidth
161
- : rect.left + window.scrollX;
162
- const maxLeft = viewportRight - menuWidth;
163
- menuLeft = Math.max(viewportLeft, Math.min(targetLeft, maxLeft));
164
- }
165
-
166
- function updateSubMenuPosition(
167
- parentItemEl: HTMLElement,
168
- subMenuEl?: HTMLElement | null
169
- ) {
170
- const rect = parentItemEl.getBoundingClientRect();
171
- const subRect = subMenuEl?.getBoundingClientRect();
172
- const subWidth = Math.min(
173
- subRect?.width ?? rect.width,
174
- window.innerWidth - 16
175
- );
176
- const spaceRight = window.innerWidth - rect.right;
177
- const spaceLeft = rect.left;
178
- const shouldFlipLeft = spaceRight < subWidth && spaceLeft > spaceRight;
179
-
180
- subMenuTop = rect.top + window.scrollY;
181
- const viewportLeft = window.scrollX;
182
- const viewportRight = window.scrollX + window.innerWidth;
183
- const targetLeft = shouldFlipLeft
184
- ? rect.left + window.scrollX - subWidth - subMenuGutter
185
- : rect.right + window.scrollX + subMenuGutter;
186
- const maxLeft = viewportRight - subWidth - subMenuGutter;
187
- subMenuLeft = Math.max(viewportLeft, Math.min(targetLeft, maxLeft));
188
- }
189
-
190
- function firstActionIndex(actions: MenuAction[]) {
191
- return actions.findIndex((a) => !isSeparator(a));
192
- }
193
-
194
- function nextActionIndex(actions: MenuAction[], current: number) {
195
- if (!actions.length) return -1;
196
- let idx = current;
197
- for (let i = 0; i < actions.length; i++) {
198
- idx = (idx + 1 + actions.length) % actions.length;
199
- if (!isSeparator(actions[idx])) return idx;
200
- }
201
- return current;
202
- }
203
-
204
- function prevActionIndex(actions: MenuAction[], current: number) {
205
- if (!actions.length) return -1;
206
- let idx = current;
207
- for (let i = 0; i < actions.length; i++) {
208
- idx = (idx - 1 + actions.length) % actions.length;
209
- if (!isSeparator(actions[idx])) return idx;
210
- }
211
- return current;
212
- }
213
-
214
- function focusMenuAction(menuItem: MenuItem, index: number) {
215
- if (index < 0 || index >= menuItem.actions.length) return;
216
- const action = menuItem.actions[index];
217
- if (!action || isSeparator(action)) return;
218
- if (!hasSubmenu(action) || openSub !== actionId(action)) {
219
- openSub = "";
220
- activeSubIndex = -1;
221
- }
222
- activeIndex = index;
223
- queueMicrotask(() => {
224
- if (open === menuItem.name) {
225
- itemRefs[actionId(action)]?.focus();
226
- }
227
- });
228
- }
229
-
230
- function focusSubAction(parentAction: MenuAction, index: number) {
231
- if (!hasSubmenu(parentAction)) return;
232
- if (index < 0 || index >= parentAction.submenu.length) return;
233
- const subAction = parentAction.submenu[index];
234
- if (!subAction || isSeparator(subAction)) return;
235
- activeSubIndex = index;
236
- queueMicrotask(() => {
237
- if (openSub === actionId(parentAction)) {
238
- subItemRefs[actionId(subAction)]?.focus();
239
- }
240
- });
241
- }
242
-
243
- function openMenu(menuItem: MenuItem, focusFirst = false) {
244
- open = menuItem.name;
245
- openSub = "";
246
- activeSubIndex = -1;
247
- const firstIndex = focusFirst ? firstActionIndex(menuItem.actions) : -1;
248
- activeIndex = firstIndex;
249
- const triggerEl = triggerRefs[menuItem.name];
250
- if (triggerEl) {
251
- updateMenuPosition(triggerEl, menuRefs[menuItem.name]);
252
- }
253
- if (focusFirst && firstIndex !== -1) {
254
- focusMenuAction(menuItem, firstIndex);
255
- }
256
- }
257
-
258
- function openSubMenu(parentAction: MenuAction, focusFirst = false) {
259
- if (!hasSubmenu(parentAction)) return;
260
- openSub = actionId(parentAction);
261
- const parentEl = itemRefs[actionId(parentAction)];
262
- if (parentEl) {
263
- updateSubMenuPosition(parentEl, subMenuRefs[actionId(parentAction)]);
264
- }
265
- const firstIndex = focusFirst ? firstActionIndex(parentAction.submenu) : -1;
266
- activeSubIndex = firstIndex;
267
- if (focusFirst && firstIndex !== -1) {
268
- focusSubAction(parentAction, firstIndex);
269
- }
270
- }
271
-
272
- // Keyboard navigation
273
- function handleTopLevelKeydown(
274
- e: KeyboardEvent,
275
- menuItem: MenuItem,
276
- index: number
277
- ) {
278
- if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
279
- e.preventDefault();
280
- openMenu(menuItem, true);
281
- } else if (e.key === "ArrowRight") {
282
- e.preventDefault();
283
- const nextIndex = (index + 1) % menus.length;
284
- triggerRefs[menus[nextIndex].name]?.focus();
285
- } else if (e.key === "ArrowLeft") {
286
- e.preventDefault();
287
- const prevIndex = (index - 1 + menus.length) % menus.length;
288
- triggerRefs[menus[prevIndex].name]?.focus();
289
- }
290
- }
291
-
292
- function handleMenuKeydown(e: KeyboardEvent, menuItem: MenuItem) {
293
- e.stopPropagation();
294
- if (!open) return;
295
-
296
- const actions = menuItem.actions;
297
- const firstIndex = firstActionIndex(actions);
298
- if (firstIndex === -1) return;
299
- const currentIndex = activeIndex === -1 ? firstIndex : activeIndex;
300
-
301
- if (e.key === "Escape") {
302
- e.preventDefault();
303
- closeMenus();
304
- triggerRefs[menuItem.name]?.focus();
305
- } else if (e.key === "ArrowDown") {
306
- e.preventDefault();
307
- const next = nextActionIndex(actions, currentIndex);
308
- focusMenuAction(menuItem, next);
309
- } else if (e.key === "ArrowUp") {
310
- e.preventDefault();
311
- const prev = prevActionIndex(actions, currentIndex);
312
- focusMenuAction(menuItem, prev);
313
- } else if (e.key === "ArrowRight") {
314
- e.preventDefault();
315
- const action = actions[currentIndex];
316
- if (action && hasSubmenu(action)) {
317
- openSubMenu(action, true);
318
- }
319
- } else if (e.key === "ArrowLeft" && openSub) {
320
- e.preventDefault();
321
- openSub = "";
322
- activeSubIndex = -1;
323
- focusMenuAction(menuItem, currentIndex);
324
- } else if (e.key === "Enter" || e.key === " ") {
325
- e.preventDefault();
326
- const action = actions[currentIndex];
327
- if (action) {
328
- if (hasSubmenu(action)) {
329
- openSubMenu(action, true);
330
- } else {
331
- select(menuItem.name, action);
332
- }
333
- }
334
- } else if (e.key === "Tab") {
335
- e.preventDefault();
336
- const target = e.shiftKey
337
- ? prevActionIndex(actions, currentIndex)
338
- : nextActionIndex(actions, currentIndex);
339
- focusMenuAction(menuItem, target);
340
- }
341
- }
342
-
343
- function handleSubMenuKeydown(
344
- e: KeyboardEvent,
345
- parentAction: MenuAction,
346
- menuName: string
347
- ) {
348
- e.stopPropagation();
349
- if (!openSub || !hasSubmenu(parentAction)) return;
350
-
351
- const subActions = parentAction.submenu;
352
- const firstIndex = firstActionIndex(subActions);
353
- if (firstIndex === -1) return;
354
- const currentIndex = activeSubIndex === -1 ? firstIndex : activeSubIndex;
355
-
356
- if (e.key === "Escape") {
357
- e.preventDefault();
358
- openSub = "";
359
- activeSubIndex = -1;
360
- itemRefs[actionId(parentAction)]?.focus();
361
- } else if (e.key === "ArrowDown") {
362
- e.preventDefault();
363
- const next = nextActionIndex(subActions, currentIndex);
364
- focusSubAction(parentAction, next);
365
- } else if (e.key === "ArrowUp") {
366
- e.preventDefault();
367
- const prev = prevActionIndex(subActions, currentIndex);
368
- focusSubAction(parentAction, prev);
369
- } else if (e.key === "ArrowLeft") {
370
- e.preventDefault();
371
- openSub = "";
372
- activeSubIndex = -1;
373
- itemRefs[actionId(parentAction)]?.focus();
374
- } else if (e.key === "Enter" || e.key === " ") {
375
- e.preventDefault();
376
- const action = subActions[currentIndex];
377
- if (action) {
378
- select(menuName, action);
379
- }
380
- } else if (e.key === "Tab") {
381
- e.preventDefault();
382
- const target = e.shiftKey
383
- ? prevActionIndex(subActions, currentIndex)
384
- : nextActionIndex(subActions, currentIndex);
385
- focusSubAction(parentAction, target);
386
- }
387
- }
388
-
389
- // Position update
390
- $effect(() => {
391
- if (open) {
392
- const triggerEl = triggerRefs[open];
393
- if (triggerEl) {
394
- updateMenuPosition(triggerEl, menuRefs[open]);
395
-
396
- const handleScrollResize = () => {
397
- updateMenuPosition(triggerEl, menuRefs[open]);
398
- };
399
-
400
- window.addEventListener("scroll", handleScrollResize, true);
401
- window.addEventListener("resize", handleScrollResize);
402
-
403
- return () => {
404
- window.removeEventListener("scroll", handleScrollResize, true);
405
- window.removeEventListener("resize", handleScrollResize);
406
- };
407
- }
408
- }
409
- });
410
-
411
- $effect(() => {
412
- if (openSub) {
413
- const itemEl = itemRefs[openSub];
414
- const subEl = subMenuRefs[openSub];
415
- if (itemEl) {
416
- updateSubMenuPosition(itemEl, subEl);
417
-
418
- const handleScrollResize = () => {
419
- updateSubMenuPosition(itemEl, subMenuRefs[openSub]);
420
- };
421
-
422
- window.addEventListener("scroll", handleScrollResize, true);
423
- window.addEventListener("resize", handleScrollResize);
424
-
425
- return () => {
426
- window.removeEventListener("scroll", handleScrollResize, true);
427
- window.removeEventListener("resize", handleScrollResize);
428
- };
429
- }
430
- }
431
- });
432
- </script>
433
-
434
- <nav class={navCls} aria-label="Menu bar">
435
- {#each menus as menuItem, idx (menuItem.name)}
436
- <div role="group" class="relative inline-block overflow-visible">
437
- <button
438
- bind:this={triggerRefs[menuItem.name]}
439
- type="button"
440
- class={cx(
441
- topBtnBaseCls,
442
- open === menuItem.name ? topButtonActive : topButtonIdle
443
- )}
444
- aria-haspopup="menu"
445
- aria-expanded={open === menuItem.name}
446
- onmousedown={(e) => e.preventDefault()}
447
- onclick={() => {
448
- if (open === menuItem.name) {
449
- closeMenus();
450
- } else {
451
- openMenu(menuItem, true);
452
- }
453
- }}
454
- onmouseenter={() => {
455
- if (open && open !== menuItem.name) {
456
- openMenu(menuItem, true);
457
- }
458
- }}
459
- onkeydown={(e) => handleTopLevelKeydown(e, menuItem, idx)}
460
- >
461
- {menuItem.name}
462
- </button>
463
- </div>
464
- {/each}
465
- </nav>
466
-
467
- <!-- Dropdown Menu -->
468
- {#if open}
469
- {#each menus as menuItem (menuItem.name)}
470
- {#if open === menuItem.name}
471
- <!-- Overlay to close -->
472
- <div
473
- role="presentation"
474
- tabindex="-1"
475
- class="fixed inset-0 z-40"
476
- onmousedown={closeMenus}
477
- ></div>
478
-
479
- <!-- Main Menu -->
480
- <div
481
- bind:this={menuRefs[menuItem.name]}
482
- class={cx(
483
- "fixed z-50 min-w-44 p-[var(--spacing-sm)] rounded-[var(--radius-sm)] shadow-[0_2px_4px_var(--shadow-color)] ",
484
- "border border-[var(--border-color-default)] bg-[var(--color-bg-surface)]"
485
- )}
486
- style={menuStyle}
487
- role="menu"
488
- tabindex="-1"
489
- onkeydown={(e) => handleMenuKeydown(e, menuItem)}
490
- >
491
- {#each menuItem.actions as action, i (actionKey(action, i))}
492
- {#if isSeparator(action)}
493
- <div
494
- class="my-[var(--spacing-xs)] mx-[var(--spacing-xs)] border-t border-[var(--border-color-default)]"
495
- role="separator"
496
- ></div>
497
- {:else}
498
- <div class="relative">
499
- <button
500
- bind:this={itemRefs[actionId(action)]}
501
- type="button"
502
- role="menuitem"
503
- class={cx(
504
- "relative text-left rounded-[var(--radius-sm)] transition-colors outline-none px-[calc(var(--spacing-sm)+var(--spacing-xs)/2)] py-[calc(var(--spacing-xs)/2)] my-[var(--spacing-xs)] mr-[var(--spacing-xs)] min-w-full flex items-center",
505
- "gap-[calc(var(--spacing-sm)+var(--spacing-xs))] hover:bg-[var(--color-bg-muted)] focus-visible:bg-[var(--color-bg-muted)]",
506
- "focus-visible:shadow-[inset_0_0_0_2px_var(--border-color-focus)]",
507
- textCls
508
- )}
509
- onmousedown={(e) => e.preventDefault()}
510
- onclick={() => {
511
- if (!hasSubmenu(action)) {
512
- select(menuItem.name, action);
513
- } else {
514
- focusMenuAction(menuItem, i);
515
- openSubMenu(action, true);
516
- }
517
- }}
518
- onmouseenter={() => {
519
- activeIndex = i;
520
- if (hasSubmenu(action) && openSub !== actionId(action)) {
521
- openSubMenu(action);
522
- } else if (!hasSubmenu(action)) {
523
- openSub = "";
524
- activeSubIndex = -1;
525
- }
526
- }}
527
- onfocus={() => {
528
- focusMenuAction(menuItem, i);
529
- }}
530
- >
531
- <span class="flex items-center gap-[var(--spacing-sm)] flex-1 min-w-0">
532
- <span class="truncate">{actionText(action)}</span>
533
- </span>
534
-
535
- <span class="flex items-center shrink-0 ml-auto gap-[var(--spacing-xs)]">
536
- {#if actionShortcut(action)}
537
- <span
538
- class={cx(
539
- "text-[var(--color-text-muted)] text-right",
540
- textCls
541
- )}
542
- >
543
- {actionShortcut(action)}
544
- </span>
545
- {/if}
546
-
547
- {#if hasSubmenu(action)}
548
- <span
549
- class={cx(
550
- "text-[var(--color-text-muted)] flex-shrink-0",
551
- textCls
552
- )}
553
- >
554
- &gt;
555
- </span>
556
- {/if}
557
- </span>
558
- </button>
559
-
560
- <!-- Sub Menu -->
561
- {#if hasSubmenu(action) && openSub === actionId(action)}
562
- <div
563
- bind:this={subMenuRefs[actionId(action)]}
564
- class={cx(
565
- "fixed z-50 min-w-44 p-[var(--spacing-sm)] rounded-[var(--radius-sm)] shadow-[0_2px_4px_var(--shadow-color)]",
566
- "border border-[var(--border-color-default)] bg-[var(--color-bg-surface)]"
567
- )}
568
- style={subMenuStyle}
569
- role="menu"
570
- tabindex="0"
571
- onkeydown={(e) =>
572
- handleSubMenuKeydown(e, action, menuItem.name)}
573
- >
574
- {#each action.submenu as sub, j (actionKey(sub, j))}
575
- {#if isSeparator(sub)}
576
- <div
577
- class="my-[var(--spacing-xs)] mx-[var(--spacing-xs)] border-t border-[var(--border-color-default)]"
578
- role="separator"
579
- ></div>
580
- {:else}
581
- <button
582
- bind:this={subItemRefs[actionId(sub)]}
583
- type="button"
584
- role="menuitem"
585
- class={cx(
586
- "relative text-left rounded-[var(--radius-sm)] transition-colors outline-none px-[calc(var(--spacing-sm)+var(--spacing-xs)/2)] py-[calc(var(--spacing-xs)/2)]",
587
- "my-[var(--spacing-xs)] mr-[var(--spacing-xs)] w-full flex items-center justify-between gap-[calc(var(--spacing-sm)+var(--spacing-xs))]",
588
- "hover:bg-[var(--color-bg-muted)] focus-visible:bg-[var(--color-bg-muted)]",
589
- "focus-visible:shadow-[inset_0_0_0_2px_var(--border-color-focus)]",
590
- "decoration-[var(--color-text-default)]",
591
- textCls
592
- )}
593
- onmousedown={(e) => e.preventDefault()}
594
- onclick={() => select(menuItem.name, sub)}
595
- onmouseenter={() => (activeSubIndex = j)}
596
- onfocus={() => (activeSubIndex = j)}
597
- >
598
- <span class="flex items-center gap-[var(--spacing-sm)] flex-1 min-w-0">
599
- <span class="truncate">{actionText(sub)}</span>
600
- </span>
601
-
602
- <span class={hotkeyColCls}>
603
- {#if actionShortcut(sub)}
604
- <span
605
- class={cx(
606
- "text-[var(--color-text-muted)]",
607
- textCls
608
- )}>{actionShortcut(sub)}</span
609
- >
610
- {/if}
611
- </span>
612
- </button>
613
- {/if}
614
- {/each}
615
- </div>
616
- {/if}
617
- </div>
618
- {/if}
619
- {/each}
620
- </div>
621
- {/if}
622
- {/each}
623
- {/if}
1
+ <!-- src/lib/Menu.svelte -->
2
+ <script lang="ts">
3
+ /**
4
+ * @component Menu
5
+ * @description A dropdown menu bar component with hover and click interactions.
6
+ *
7
+ * @prop menus {MenuItem[]} - Menu definitions with actions
8
+ * @default []
9
+ *
10
+ * @prop onSelect {(menu: string, action: MenuAction) => void} - Fired when an action is chosen
11
+ * @default () => {}
12
+ *
13
+ * @prop class {string} - Extra classes applied to the menu bar
14
+ * @default ""
15
+ *
16
+ * @prop sz {SizeKey} - Size preset for spacing and text
17
+ * @options xs|sm|md|lg|xl
18
+ * @default sm
19
+ *
20
+ * @note Fully keyboard-safe for focus and mouse interactions.
21
+ * @note Submenus open on hover when another menu is already open.
22
+ * @note Actions that match size keys (`xs`, `sm`, `md`, `lg`, `xl`) are automatically highlighted to reflect the current UI size.
23
+ * @note Uses the same CSS variable architecture as Tabs for consistent look across components.
24
+ * @note No slots; fully controlled via the `menus` structure and `onSelect`.
25
+ */
26
+ import type { SizeKey, MenuItem, MenuAction } from "./types";
27
+ import { TEXT } from "./types";
28
+ import { cx } from "../utils";
29
+
30
+ type Props = {
31
+ menus?: MenuItem[];
32
+ onSelect?: (menu: string, action: MenuAction) => void;
33
+ class?: string;
34
+ sz?: SizeKey;
35
+ };
36
+
37
+ let {
38
+ menus = [],
39
+ onSelect = () => {},
40
+ class: externalClass = "",
41
+ sz = "sm",
42
+ }: Props = $props();
43
+
44
+ let open = $state<string>("");
45
+ let openSub = $state<string>("");
46
+ let activeIndex = $state(-1);
47
+ let activeSubIndex = $state(-1);
48
+
49
+ // Refs for focus control
50
+ let triggerRefs = $state<Record<string, HTMLButtonElement>>({});
51
+ let menuRefs = $state<Record<string, HTMLDivElement>>({});
52
+ let itemRefs = $state<Record<string, HTMLButtonElement>>({});
53
+ let subItemRefs = $state<Record<string, HTMLButtonElement>>({});
54
+
55
+ // Positioning
56
+ let menuTop = $state(0);
57
+ let menuLeft = $state(0);
58
+
59
+ let subMenuRefs = $state<Record<string, HTMLDivElement>>({});
60
+ let subMenuTop = $state(0);
61
+ let subMenuLeft = $state(0);
62
+
63
+ const sizes: Record<SizeKey, string> = {
64
+ xs: "h-[calc(var(--spacing-md)+var(--spacing-sm)+var(--spacing-xs))] px-[calc(var(--spacing-sm)+var(--spacing-xs))]",
65
+ sm: "h-[var(--spacing-xl)] px-[calc(var(--spacing-sm)+var(--spacing-xs))]",
66
+ md: "h-[calc(var(--spacing-xl)+var(--spacing-xs))] px-[var(--spacing-md)]",
67
+ lg: "h-[calc(var(--spacing-xl)+var(--spacing-sm))] px-[var(--spacing-md)]",
68
+ xl: "h-[calc(var(--spacing-xl)+var(--spacing-sm)+var(--spacing-xs))] px-[calc(var(--spacing-md)+var(--spacing-xs))]",
69
+ };
70
+
71
+ const navBase =
72
+ "flex items-stretch pl-[var(--spacing-sm)] gap-[var(--spacing-xs)] border-b relative z-10 bg-[var(--color-bg-surface)] text-[var(--color-text-default)] border-[var(--border-color-default)]";
73
+
74
+ const subMenuGutter = 8;
75
+
76
+ const topButtonBase =
77
+ "px-[var(--spacing-md)] rounded-[var(--radius-sm)] leading-none transition-colors outline-none [@media(pointer:coarse)]:min-h-11 focus-visible:shadow-[inset_0_0_0_2px_var(--border-color-focus)]";
78
+
79
+ const topButtonActive =
80
+ "bg-[var(--color-bg-muted)] text-[var(--color-text-default)]";
81
+ const topButtonIdle =
82
+ "hover:bg-[var(--color-bg-muted)] text-[var(--color-text-default)]";
83
+
84
+ const menuStyle = $derived(
85
+ `position:fixed; top:${menuTop}px; left:${menuLeft}px; width:max-content; max-width:calc(100vw - 16px);`
86
+ );
87
+ const subMenuStyle = $derived(
88
+ `position:fixed; top:${subMenuTop}px; left:${subMenuLeft}px; width:max-content; max-width:calc(100vw - 16px);`
89
+ );
90
+
91
+ const textCls = $derived(TEXT[sz]);
92
+ const hotkeyColCls = "flex items-center shrink-0";
93
+
94
+ const navCls = $derived(cx(navBase, sizes[sz], textCls, externalClass));
95
+ const topBtnBaseCls = $derived(cx(topButtonBase, sizes[sz], textCls));
96
+
97
+ function actionText(a: MenuAction) {
98
+ if (typeof a === "string") return a;
99
+ return a.label;
100
+ }
101
+
102
+ function actionId(a: MenuAction) {
103
+ if (typeof a === "string") return a;
104
+ return a.id ?? a.label ?? "";
105
+ }
106
+
107
+ function actionShortcut(a: MenuAction) {
108
+ return typeof a === "string" ? "" : (a.shortcut ?? "");
109
+ }
110
+
111
+ function isSeparator(
112
+ a: MenuAction
113
+ ): a is Exclude<MenuAction, string> & { type: "separator" } {
114
+ return typeof a !== "string" && "type" in a && a.type === "separator";
115
+ }
116
+
117
+ function hasSubmenu(
118
+ a: MenuAction
119
+ ): a is Exclude<MenuAction, string> & { submenu: MenuAction[] } {
120
+ return (
121
+ typeof a !== "string" && Array.isArray(a.submenu) && a.submenu.length > 0
122
+ );
123
+ }
124
+
125
+ function actionKey(a: MenuAction, idx: number) {
126
+ const id = actionId(a);
127
+ return id || `__action-${idx}`;
128
+ }
129
+
130
+ function select(menu: string, action: MenuAction) {
131
+ closeMenus();
132
+ onSelect(menu, action);
133
+ }
134
+
135
+ function closeMenus() {
136
+ open = "";
137
+ openSub = "";
138
+ activeIndex = -1;
139
+ activeSubIndex = -1;
140
+ }
141
+
142
+ // Positioning dropdown
143
+ function updateMenuPosition(
144
+ triggerEl: HTMLElement,
145
+ menuEl?: HTMLElement | null
146
+ ) {
147
+ const rect = triggerEl.getBoundingClientRect();
148
+ const menuWidth = Math.min(
149
+ menuEl?.getBoundingClientRect().width ?? rect.width,
150
+ window.innerWidth - 16
151
+ );
152
+ const spaceRight = window.innerWidth - rect.left;
153
+ const spaceLeft = rect.right;
154
+ const alignRight = spaceRight < menuWidth && spaceLeft > spaceRight;
155
+ const viewportLeft = window.scrollX;
156
+ const viewportRight = window.scrollX + window.innerWidth;
157
+
158
+ menuTop = rect.bottom + window.scrollY;
159
+ const targetLeft = alignRight
160
+ ? rect.right + window.scrollX - menuWidth
161
+ : rect.left + window.scrollX;
162
+ const maxLeft = viewportRight - menuWidth;
163
+ menuLeft = Math.max(viewportLeft, Math.min(targetLeft, maxLeft));
164
+ }
165
+
166
+ function updateSubMenuPosition(
167
+ parentItemEl: HTMLElement,
168
+ subMenuEl?: HTMLElement | null
169
+ ) {
170
+ const rect = parentItemEl.getBoundingClientRect();
171
+ const subRect = subMenuEl?.getBoundingClientRect();
172
+ const subWidth = Math.min(
173
+ subRect?.width ?? rect.width,
174
+ window.innerWidth - 16
175
+ );
176
+ const spaceRight = window.innerWidth - rect.right;
177
+ const spaceLeft = rect.left;
178
+ const shouldFlipLeft = spaceRight < subWidth && spaceLeft > spaceRight;
179
+
180
+ subMenuTop = rect.top + window.scrollY;
181
+ const viewportLeft = window.scrollX;
182
+ const viewportRight = window.scrollX + window.innerWidth;
183
+ const targetLeft = shouldFlipLeft
184
+ ? rect.left + window.scrollX - subWidth - subMenuGutter
185
+ : rect.right + window.scrollX + subMenuGutter;
186
+ const maxLeft = viewportRight - subWidth - subMenuGutter;
187
+ subMenuLeft = Math.max(viewportLeft, Math.min(targetLeft, maxLeft));
188
+ }
189
+
190
+ function firstActionIndex(actions: MenuAction[]) {
191
+ return actions.findIndex((a) => !isSeparator(a));
192
+ }
193
+
194
+ function nextActionIndex(actions: MenuAction[], current: number) {
195
+ if (!actions.length) return -1;
196
+ let idx = current;
197
+ for (let i = 0; i < actions.length; i++) {
198
+ idx = (idx + 1 + actions.length) % actions.length;
199
+ if (!isSeparator(actions[idx])) return idx;
200
+ }
201
+ return current;
202
+ }
203
+
204
+ function prevActionIndex(actions: MenuAction[], current: number) {
205
+ if (!actions.length) return -1;
206
+ let idx = current;
207
+ for (let i = 0; i < actions.length; i++) {
208
+ idx = (idx - 1 + actions.length) % actions.length;
209
+ if (!isSeparator(actions[idx])) return idx;
210
+ }
211
+ return current;
212
+ }
213
+
214
+ function focusMenuAction(menuItem: MenuItem, index: number) {
215
+ if (index < 0 || index >= menuItem.actions.length) return;
216
+ const action = menuItem.actions[index];
217
+ if (!action || isSeparator(action)) return;
218
+ if (!hasSubmenu(action) || openSub !== actionId(action)) {
219
+ openSub = "";
220
+ activeSubIndex = -1;
221
+ }
222
+ activeIndex = index;
223
+ queueMicrotask(() => {
224
+ if (open === menuItem.name) {
225
+ itemRefs[actionId(action)]?.focus();
226
+ }
227
+ });
228
+ }
229
+
230
+ function focusSubAction(parentAction: MenuAction, index: number) {
231
+ if (!hasSubmenu(parentAction)) return;
232
+ if (index < 0 || index >= parentAction.submenu.length) return;
233
+ const subAction = parentAction.submenu[index];
234
+ if (!subAction || isSeparator(subAction)) return;
235
+ activeSubIndex = index;
236
+ queueMicrotask(() => {
237
+ if (openSub === actionId(parentAction)) {
238
+ subItemRefs[actionId(subAction)]?.focus();
239
+ }
240
+ });
241
+ }
242
+
243
+ function openMenu(menuItem: MenuItem, focusFirst = false) {
244
+ open = menuItem.name;
245
+ openSub = "";
246
+ activeSubIndex = -1;
247
+ const firstIndex = focusFirst ? firstActionIndex(menuItem.actions) : -1;
248
+ activeIndex = firstIndex;
249
+ const triggerEl = triggerRefs[menuItem.name];
250
+ if (triggerEl) {
251
+ updateMenuPosition(triggerEl, menuRefs[menuItem.name]);
252
+ }
253
+ if (focusFirst && firstIndex !== -1) {
254
+ focusMenuAction(menuItem, firstIndex);
255
+ }
256
+ }
257
+
258
+ function openSubMenu(parentAction: MenuAction, focusFirst = false) {
259
+ if (!hasSubmenu(parentAction)) return;
260
+ openSub = actionId(parentAction);
261
+ const parentEl = itemRefs[actionId(parentAction)];
262
+ if (parentEl) {
263
+ updateSubMenuPosition(parentEl, subMenuRefs[actionId(parentAction)]);
264
+ }
265
+ const firstIndex = focusFirst ? firstActionIndex(parentAction.submenu) : -1;
266
+ activeSubIndex = firstIndex;
267
+ if (focusFirst && firstIndex !== -1) {
268
+ focusSubAction(parentAction, firstIndex);
269
+ }
270
+ }
271
+
272
+ // Keyboard navigation
273
+ function handleTopLevelKeydown(
274
+ e: KeyboardEvent,
275
+ menuItem: MenuItem,
276
+ index: number
277
+ ) {
278
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
279
+ e.preventDefault();
280
+ openMenu(menuItem, true);
281
+ } else if (e.key === "ArrowRight") {
282
+ e.preventDefault();
283
+ const nextIndex = (index + 1) % menus.length;
284
+ triggerRefs[menus[nextIndex].name]?.focus();
285
+ } else if (e.key === "ArrowLeft") {
286
+ e.preventDefault();
287
+ const prevIndex = (index - 1 + menus.length) % menus.length;
288
+ triggerRefs[menus[prevIndex].name]?.focus();
289
+ }
290
+ }
291
+
292
+ function handleMenuKeydown(e: KeyboardEvent, menuItem: MenuItem) {
293
+ e.stopPropagation();
294
+ if (!open) return;
295
+
296
+ const actions = menuItem.actions;
297
+ const firstIndex = firstActionIndex(actions);
298
+ if (firstIndex === -1) return;
299
+ const currentIndex = activeIndex === -1 ? firstIndex : activeIndex;
300
+
301
+ if (e.key === "Escape") {
302
+ e.preventDefault();
303
+ closeMenus();
304
+ triggerRefs[menuItem.name]?.focus();
305
+ } else if (e.key === "ArrowDown") {
306
+ e.preventDefault();
307
+ const next = nextActionIndex(actions, currentIndex);
308
+ focusMenuAction(menuItem, next);
309
+ } else if (e.key === "ArrowUp") {
310
+ e.preventDefault();
311
+ const prev = prevActionIndex(actions, currentIndex);
312
+ focusMenuAction(menuItem, prev);
313
+ } else if (e.key === "ArrowRight") {
314
+ e.preventDefault();
315
+ const action = actions[currentIndex];
316
+ if (action && hasSubmenu(action)) {
317
+ openSubMenu(action, true);
318
+ }
319
+ } else if (e.key === "ArrowLeft" && openSub) {
320
+ e.preventDefault();
321
+ openSub = "";
322
+ activeSubIndex = -1;
323
+ focusMenuAction(menuItem, currentIndex);
324
+ } else if (e.key === "Enter" || e.key === " ") {
325
+ e.preventDefault();
326
+ const action = actions[currentIndex];
327
+ if (action) {
328
+ if (hasSubmenu(action)) {
329
+ openSubMenu(action, true);
330
+ } else {
331
+ select(menuItem.name, action);
332
+ }
333
+ }
334
+ } else if (e.key === "Tab") {
335
+ e.preventDefault();
336
+ const target = e.shiftKey
337
+ ? prevActionIndex(actions, currentIndex)
338
+ : nextActionIndex(actions, currentIndex);
339
+ focusMenuAction(menuItem, target);
340
+ }
341
+ }
342
+
343
+ function handleSubMenuKeydown(
344
+ e: KeyboardEvent,
345
+ parentAction: MenuAction,
346
+ menuName: string
347
+ ) {
348
+ e.stopPropagation();
349
+ if (!openSub || !hasSubmenu(parentAction)) return;
350
+
351
+ const subActions = parentAction.submenu;
352
+ const firstIndex = firstActionIndex(subActions);
353
+ if (firstIndex === -1) return;
354
+ const currentIndex = activeSubIndex === -1 ? firstIndex : activeSubIndex;
355
+
356
+ if (e.key === "Escape") {
357
+ e.preventDefault();
358
+ openSub = "";
359
+ activeSubIndex = -1;
360
+ itemRefs[actionId(parentAction)]?.focus();
361
+ } else if (e.key === "ArrowDown") {
362
+ e.preventDefault();
363
+ const next = nextActionIndex(subActions, currentIndex);
364
+ focusSubAction(parentAction, next);
365
+ } else if (e.key === "ArrowUp") {
366
+ e.preventDefault();
367
+ const prev = prevActionIndex(subActions, currentIndex);
368
+ focusSubAction(parentAction, prev);
369
+ } else if (e.key === "ArrowLeft") {
370
+ e.preventDefault();
371
+ openSub = "";
372
+ activeSubIndex = -1;
373
+ itemRefs[actionId(parentAction)]?.focus();
374
+ } else if (e.key === "Enter" || e.key === " ") {
375
+ e.preventDefault();
376
+ const action = subActions[currentIndex];
377
+ if (action) {
378
+ select(menuName, action);
379
+ }
380
+ } else if (e.key === "Tab") {
381
+ e.preventDefault();
382
+ const target = e.shiftKey
383
+ ? prevActionIndex(subActions, currentIndex)
384
+ : nextActionIndex(subActions, currentIndex);
385
+ focusSubAction(parentAction, target);
386
+ }
387
+ }
388
+
389
+ // Position update
390
+ $effect(() => {
391
+ if (open) {
392
+ const triggerEl = triggerRefs[open];
393
+ if (triggerEl) {
394
+ updateMenuPosition(triggerEl, menuRefs[open]);
395
+
396
+ const handleScrollResize = () => {
397
+ updateMenuPosition(triggerEl, menuRefs[open]);
398
+ };
399
+
400
+ window.addEventListener("scroll", handleScrollResize, true);
401
+ window.addEventListener("resize", handleScrollResize);
402
+
403
+ return () => {
404
+ window.removeEventListener("scroll", handleScrollResize, true);
405
+ window.removeEventListener("resize", handleScrollResize);
406
+ };
407
+ }
408
+ }
409
+ });
410
+
411
+ $effect(() => {
412
+ if (openSub) {
413
+ const itemEl = itemRefs[openSub];
414
+ const subEl = subMenuRefs[openSub];
415
+ if (itemEl) {
416
+ updateSubMenuPosition(itemEl, subEl);
417
+
418
+ const handleScrollResize = () => {
419
+ updateSubMenuPosition(itemEl, subMenuRefs[openSub]);
420
+ };
421
+
422
+ window.addEventListener("scroll", handleScrollResize, true);
423
+ window.addEventListener("resize", handleScrollResize);
424
+
425
+ return () => {
426
+ window.removeEventListener("scroll", handleScrollResize, true);
427
+ window.removeEventListener("resize", handleScrollResize);
428
+ };
429
+ }
430
+ }
431
+ });
432
+ </script>
433
+
434
+ <nav class={navCls} aria-label="Menu bar">
435
+ {#each menus as menuItem, idx (menuItem.name)}
436
+ <div role="group" class="relative inline-block overflow-visible">
437
+ <button
438
+ bind:this={triggerRefs[menuItem.name]}
439
+ type="button"
440
+ class={cx(
441
+ topBtnBaseCls,
442
+ open === menuItem.name ? topButtonActive : topButtonIdle
443
+ )}
444
+ aria-haspopup="menu"
445
+ aria-expanded={open === menuItem.name}
446
+ onmousedown={(e) => e.preventDefault()}
447
+ onclick={() => {
448
+ if (open === menuItem.name) {
449
+ closeMenus();
450
+ } else {
451
+ openMenu(menuItem, true);
452
+ }
453
+ }}
454
+ onmouseenter={() => {
455
+ if (open && open !== menuItem.name) {
456
+ openMenu(menuItem, true);
457
+ }
458
+ }}
459
+ onkeydown={(e) => handleTopLevelKeydown(e, menuItem, idx)}
460
+ >
461
+ {menuItem.name}
462
+ </button>
463
+ </div>
464
+ {/each}
465
+ </nav>
466
+
467
+ <!-- Dropdown Menu -->
468
+ {#if open}
469
+ {#each menus as menuItem (menuItem.name)}
470
+ {#if open === menuItem.name}
471
+ <!-- Overlay to close -->
472
+ <div
473
+ role="presentation"
474
+ tabindex="-1"
475
+ class="fixed inset-0 z-40"
476
+ onmousedown={closeMenus}
477
+ ></div>
478
+
479
+ <!-- Main Menu -->
480
+ <div
481
+ bind:this={menuRefs[menuItem.name]}
482
+ class={cx(
483
+ "fixed z-50 min-w-44 p-[var(--spacing-sm)] rounded-[var(--radius-sm)] shadow-[0_2px_4px_var(--shadow-color)] ",
484
+ "border border-[var(--border-color-default)] bg-[var(--color-bg-surface)]"
485
+ )}
486
+ style={menuStyle}
487
+ role="menu"
488
+ tabindex="-1"
489
+ onkeydown={(e) => handleMenuKeydown(e, menuItem)}
490
+ >
491
+ {#each menuItem.actions as action, i (actionKey(action, i))}
492
+ {#if isSeparator(action)}
493
+ <div
494
+ class="my-[var(--spacing-xs)] mx-[var(--spacing-xs)] border-t border-[var(--border-color-default)]"
495
+ role="separator"
496
+ ></div>
497
+ {:else}
498
+ <div class="relative">
499
+ <button
500
+ bind:this={itemRefs[actionId(action)]}
501
+ type="button"
502
+ role="menuitem"
503
+ class={cx(
504
+ "relative text-left rounded-[var(--radius-sm)] transition-colors outline-none px-[calc(var(--spacing-sm)+var(--spacing-xs)/2)] py-[calc(var(--spacing-xs)/2)] my-[var(--spacing-xs)] mr-[var(--spacing-xs)] min-w-full flex items-center",
505
+ "gap-[calc(var(--spacing-sm)+var(--spacing-xs))] hover:bg-[var(--color-bg-muted)] focus-visible:bg-[var(--color-bg-muted)]",
506
+ "focus-visible:shadow-[inset_0_0_0_2px_var(--border-color-focus)]",
507
+ textCls
508
+ )}
509
+ onmousedown={(e) => e.preventDefault()}
510
+ onclick={() => {
511
+ if (!hasSubmenu(action)) {
512
+ select(menuItem.name, action);
513
+ } else {
514
+ focusMenuAction(menuItem, i);
515
+ openSubMenu(action, true);
516
+ }
517
+ }}
518
+ onmouseenter={() => {
519
+ activeIndex = i;
520
+ if (hasSubmenu(action) && openSub !== actionId(action)) {
521
+ openSubMenu(action);
522
+ } else if (!hasSubmenu(action)) {
523
+ openSub = "";
524
+ activeSubIndex = -1;
525
+ }
526
+ }}
527
+ onfocus={() => {
528
+ focusMenuAction(menuItem, i);
529
+ }}
530
+ >
531
+ <span class="flex items-center gap-[var(--spacing-sm)] flex-1 min-w-0">
532
+ <span class="truncate">{actionText(action)}</span>
533
+ </span>
534
+
535
+ <span class="flex items-center shrink-0 ml-auto gap-[var(--spacing-xs)]">
536
+ {#if actionShortcut(action)}
537
+ <span
538
+ class={cx(
539
+ "text-[var(--color-text-muted)] text-right",
540
+ textCls
541
+ )}
542
+ >
543
+ {actionShortcut(action)}
544
+ </span>
545
+ {/if}
546
+
547
+ {#if hasSubmenu(action)}
548
+ <span
549
+ class={cx(
550
+ "text-[var(--color-text-muted)] flex-shrink-0",
551
+ textCls
552
+ )}
553
+ >
554
+ &gt;
555
+ </span>
556
+ {/if}
557
+ </span>
558
+ </button>
559
+
560
+ <!-- Sub Menu -->
561
+ {#if hasSubmenu(action) && openSub === actionId(action)}
562
+ <div
563
+ bind:this={subMenuRefs[actionId(action)]}
564
+ class={cx(
565
+ "fixed z-50 min-w-44 p-[var(--spacing-sm)] rounded-[var(--radius-sm)] shadow-[0_2px_4px_var(--shadow-color)]",
566
+ "border border-[var(--border-color-default)] bg-[var(--color-bg-surface)]"
567
+ )}
568
+ style={subMenuStyle}
569
+ role="menu"
570
+ tabindex="0"
571
+ onkeydown={(e) =>
572
+ handleSubMenuKeydown(e, action, menuItem.name)}
573
+ >
574
+ {#each action.submenu as sub, j (actionKey(sub, j))}
575
+ {#if isSeparator(sub)}
576
+ <div
577
+ class="my-[var(--spacing-xs)] mx-[var(--spacing-xs)] border-t border-[var(--border-color-default)]"
578
+ role="separator"
579
+ ></div>
580
+ {:else}
581
+ <button
582
+ bind:this={subItemRefs[actionId(sub)]}
583
+ type="button"
584
+ role="menuitem"
585
+ class={cx(
586
+ "relative text-left rounded-[var(--radius-sm)] transition-colors outline-none px-[calc(var(--spacing-sm)+var(--spacing-xs)/2)] py-[calc(var(--spacing-xs)/2)]",
587
+ "my-[var(--spacing-xs)] mr-[var(--spacing-xs)] w-full flex items-center justify-between gap-[calc(var(--spacing-sm)+var(--spacing-xs))]",
588
+ "hover:bg-[var(--color-bg-muted)] focus-visible:bg-[var(--color-bg-muted)]",
589
+ "focus-visible:shadow-[inset_0_0_0_2px_var(--border-color-focus)]",
590
+ "decoration-[var(--color-text-default)]",
591
+ textCls
592
+ )}
593
+ onmousedown={(e) => e.preventDefault()}
594
+ onclick={() => select(menuItem.name, sub)}
595
+ onmouseenter={() => (activeSubIndex = j)}
596
+ onfocus={() => (activeSubIndex = j)}
597
+ >
598
+ <span class="flex items-center gap-[var(--spacing-sm)] flex-1 min-w-0">
599
+ <span class="truncate">{actionText(sub)}</span>
600
+ </span>
601
+
602
+ <span class={hotkeyColCls}>
603
+ {#if actionShortcut(sub)}
604
+ <span
605
+ class={cx(
606
+ "text-[var(--color-text-muted)]",
607
+ textCls
608
+ )}>{actionShortcut(sub)}</span
609
+ >
610
+ {/if}
611
+ </span>
612
+ </button>
613
+ {/if}
614
+ {/each}
615
+ </div>
616
+ {/if}
617
+ </div>
618
+ {/if}
619
+ {/each}
620
+ </div>
621
+ {/if}
622
+ {/each}
623
+ {/if}