svelte-comp 1.3.3 → 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 (138) hide show
  1. package/LICENSE.md +21 -21
  2. package/README.md +101 -100
  3. package/dist/App.svelte +507 -507
  4. package/dist/Container.svelte +59 -59
  5. package/dist/app.css +234 -235
  6. package/dist/app.d.ts +10 -0
  7. package/dist/lib/Accordion.svelte +155 -155
  8. package/dist/lib/Badge.svelte +44 -44
  9. package/dist/lib/Button.svelte +185 -170
  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/Carousel.svelte.d.ts +1 -1
  14. package/dist/lib/CheckBox.svelte +210 -210
  15. package/dist/lib/CodeView.svelte +308 -307
  16. package/dist/lib/ColorPicker.svelte +159 -159
  17. package/dist/lib/ContextMenu.svelte +328 -322
  18. package/dist/lib/DatePicker.svelte +246 -246
  19. package/dist/lib/Dialog.svelte +233 -233
  20. package/dist/lib/Field.svelte +299 -299
  21. package/dist/lib/FilePicker.svelte +295 -240
  22. package/dist/lib/FilePicker.svelte.d.ts +6 -1
  23. package/dist/lib/Form.svelte +438 -438
  24. package/dist/lib/Hamburger.svelte +217 -217
  25. package/dist/lib/InstallPWA.svelte +94 -94
  26. package/dist/lib/Menu.svelte +623 -623
  27. package/dist/lib/NoticeBase.svelte +140 -140
  28. package/dist/lib/PaginatedCard.svelte +73 -73
  29. package/dist/lib/Pagination.svelte +119 -119
  30. package/dist/lib/PrimaryColorSelect.svelte +111 -111
  31. package/dist/lib/ProgressBar.svelte +141 -141
  32. package/dist/lib/ProgressCircle.svelte +190 -190
  33. package/dist/lib/Radio.svelte +189 -189
  34. package/dist/lib/SearchInput.svelte +104 -104
  35. package/dist/lib/Select.svelte +524 -524
  36. package/dist/lib/Slider.svelte +253 -253
  37. package/dist/lib/Splitter.svelte +159 -150
  38. package/dist/lib/Switch.svelte +168 -167
  39. package/dist/lib/Table.svelte +299 -299
  40. package/dist/lib/Tabs.svelte +213 -213
  41. package/dist/lib/ThemeToggle.svelte +128 -127
  42. package/dist/lib/TimePicker.svelte +312 -312
  43. package/dist/lib/TimePickerNew.svelte +634 -0
  44. package/dist/lib/TimePickerNew.svelte.d.ts +49 -0
  45. package/dist/lib/Toast.svelte +123 -123
  46. package/dist/lib/Tooltip.svelte +110 -110
  47. package/dist/lib/Topbar.svelte +107 -107
  48. package/dist/lib/__tests__/Accordion.test.d.ts +1 -0
  49. package/dist/lib/__tests__/Accordion.test.js +171 -0
  50. package/dist/lib/__tests__/Badge.test.d.ts +1 -0
  51. package/dist/lib/__tests__/Badge.test.js +41 -0
  52. package/dist/lib/__tests__/Button.test.d.ts +1 -0
  53. package/dist/lib/__tests__/Button.test.js +269 -0
  54. package/dist/lib/__tests__/Calendar.test.d.ts +1 -0
  55. package/dist/lib/__tests__/Calendar.test.js +171 -0
  56. package/dist/lib/__tests__/Card.test.d.ts +1 -0
  57. package/dist/lib/__tests__/Card.test.js +148 -0
  58. package/dist/lib/__tests__/Carousel.test.d.ts +1 -0
  59. package/dist/lib/__tests__/Carousel.test.js +439 -0
  60. package/dist/lib/__tests__/CheckBox.test.d.ts +1 -0
  61. package/dist/lib/__tests__/CheckBox.test.js +152 -0
  62. package/dist/lib/__tests__/CodeView.test.d.ts +1 -0
  63. package/dist/lib/__tests__/CodeView.test.js +157 -0
  64. package/dist/lib/__tests__/ColorPicker.test.d.ts +1 -0
  65. package/dist/lib/__tests__/ColorPicker.test.js +93 -0
  66. package/dist/lib/__tests__/ContextMenu.test.d.ts +1 -0
  67. package/dist/lib/__tests__/ContextMenu.test.js +67 -0
  68. package/dist/lib/__tests__/DatePicker.test.d.ts +1 -0
  69. package/dist/lib/__tests__/DatePicker.test.js +108 -0
  70. package/dist/lib/__tests__/Dialog.test.d.ts +1 -0
  71. package/dist/lib/__tests__/Dialog.test.js +183 -0
  72. package/dist/lib/__tests__/Field.test.d.ts +1 -0
  73. package/dist/lib/__tests__/Field.test.js +190 -0
  74. package/dist/lib/__tests__/FilePicker.test.d.ts +1 -0
  75. package/dist/lib/__tests__/FilePicker.test.js +179 -0
  76. package/dist/lib/__tests__/Form.integration.test.d.ts +1 -0
  77. package/dist/lib/__tests__/Form.integration.test.js +158 -0
  78. package/dist/lib/__tests__/Form.test.d.ts +1 -0
  79. package/dist/lib/__tests__/Form.test.js +463 -0
  80. package/dist/lib/__tests__/Hamburger.test.d.ts +1 -0
  81. package/dist/lib/__tests__/Hamburger.test.js +161 -0
  82. package/dist/lib/__tests__/InstallPWA.test.d.ts +1 -0
  83. package/dist/lib/__tests__/InstallPWA.test.js +15 -0
  84. package/dist/lib/__tests__/Menu.test.d.ts +1 -0
  85. package/dist/lib/__tests__/Menu.test.js +285 -0
  86. package/dist/lib/__tests__/NoticeBase.test.d.ts +1 -0
  87. package/dist/lib/__tests__/NoticeBase.test.js +60 -0
  88. package/dist/lib/__tests__/PaginatedCard.test.d.ts +1 -0
  89. package/dist/lib/__tests__/PaginatedCard.test.js +89 -0
  90. package/dist/lib/__tests__/Pagination.test.d.ts +1 -0
  91. package/dist/lib/__tests__/Pagination.test.js +168 -0
  92. package/dist/lib/__tests__/PrimaryColorSelect.test.d.ts +1 -0
  93. package/dist/lib/__tests__/PrimaryColorSelect.test.js +92 -0
  94. package/dist/lib/__tests__/ProgressBar.test.d.ts +1 -0
  95. package/dist/lib/__tests__/ProgressBar.test.js +69 -0
  96. package/dist/lib/__tests__/ProgressCircle.test.d.ts +1 -0
  97. package/dist/lib/__tests__/ProgressCircle.test.js +71 -0
  98. package/dist/lib/__tests__/Radio.test.d.ts +1 -0
  99. package/dist/lib/__tests__/Radio.test.js +127 -0
  100. package/dist/lib/__tests__/SearchInput.test.d.ts +1 -0
  101. package/dist/lib/__tests__/SearchInput.test.js +80 -0
  102. package/dist/lib/__tests__/Select.test.d.ts +1 -0
  103. package/dist/lib/__tests__/Select.test.js +408 -0
  104. package/dist/lib/__tests__/Slider.test.d.ts +1 -0
  105. package/dist/lib/__tests__/Slider.test.js +213 -0
  106. package/dist/lib/__tests__/Splitter.test.d.ts +1 -0
  107. package/dist/lib/__tests__/Splitter.test.js +87 -0
  108. package/dist/lib/__tests__/Switch.test.d.ts +1 -0
  109. package/dist/lib/__tests__/Switch.test.js +97 -0
  110. package/dist/lib/__tests__/Table.test.d.ts +1 -0
  111. package/dist/lib/__tests__/Table.test.js +349 -0
  112. package/dist/lib/__tests__/Tabs.test.d.ts +1 -0
  113. package/dist/lib/__tests__/Tabs.test.js +262 -0
  114. package/dist/lib/__tests__/ThemeToggle.test.d.ts +1 -0
  115. package/dist/lib/__tests__/ThemeToggle.test.js +84 -0
  116. package/dist/lib/__tests__/TimePicker.test.d.ts +1 -0
  117. package/dist/lib/__tests__/TimePicker.test.js +146 -0
  118. package/dist/lib/__tests__/TimePickerNew.test.d.ts +1 -0
  119. package/dist/lib/__tests__/TimePickerNew.test.js +322 -0
  120. package/dist/lib/__tests__/Toast.test.d.ts +1 -0
  121. package/dist/lib/__tests__/Toast.test.js +135 -0
  122. package/dist/lib/__tests__/Tooltip.test.d.ts +1 -0
  123. package/dist/lib/__tests__/Tooltip.test.js +171 -0
  124. package/dist/lib/__tests__/Topbar.test.d.ts +1 -0
  125. package/dist/lib/__tests__/Topbar.test.js +25 -0
  126. package/dist/lib/__tests__/setupLangContext.d.ts +1 -0
  127. package/dist/lib/__tests__/setupLangContext.js +65 -0
  128. package/dist/lib/__tests__/storage.test.d.ts +1 -0
  129. package/dist/lib/__tests__/storage.test.js +124 -0
  130. package/dist/lib/__tests__/utils.test.d.ts +1 -0
  131. package/dist/lib/__tests__/utils.test.js +11 -0
  132. package/dist/lib/index.d.ts +1 -0
  133. package/dist/lib/index.js +1 -0
  134. package/dist/lib/lang.d.ts +4 -0
  135. package/dist/lib/lang.js +4 -0
  136. package/dist/styles.css +234 -232
  137. package/dist/utils/index.js +15 -4
  138. 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-7 px-3",
65
- sm: "h-8 px-3",
66
- md: "h-9 px-4",
67
- lg: "h-10 px-4",
68
- xl: "h-11 px-5",
69
- };
70
-
71
- const navBase =
72
- "flex items-stretch pl-2 gap-1 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-4 rounded-xs leading-none transition-colors outline-none 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-2 rounded-xs 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-1 mx-1 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-xs transition-colors outline-none px-1.5 py-0.5 my-1 mr-1 min-w-full flex items-center",
505
- "gap-3 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-2 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-1">
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-2 rounded-xs 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-1 mx-1 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-xs transition-colors outline-none px-1.5 py-0.5",
587
- "my-1 mr-1 w-full flex items-center justify-between gap-3",
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-2 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}