mtrl 0.3.5 → 0.3.7

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 (65) hide show
  1. package/package.json +1 -1
  2. package/src/components/button/api.ts +16 -0
  3. package/src/components/button/types.ts +9 -0
  4. package/src/components/menu/api.ts +144 -267
  5. package/src/components/menu/config.ts +84 -40
  6. package/src/components/menu/features/anchor.ts +243 -0
  7. package/src/components/menu/features/controller.ts +1167 -0
  8. package/src/components/menu/features/index.ts +5 -0
  9. package/src/components/menu/features/position.ts +353 -0
  10. package/src/components/menu/index.ts +31 -63
  11. package/src/components/menu/menu.ts +72 -104
  12. package/src/components/menu/types.ts +264 -447
  13. package/src/components/select/api.ts +78 -0
  14. package/src/components/select/config.ts +76 -0
  15. package/src/components/select/features.ts +317 -0
  16. package/src/components/select/index.ts +38 -0
  17. package/src/components/select/select.ts +73 -0
  18. package/src/components/select/types.ts +355 -0
  19. package/src/components/textfield/api.ts +78 -6
  20. package/src/components/textfield/features/index.ts +17 -0
  21. package/src/components/textfield/features/leading-icon.ts +127 -0
  22. package/src/components/textfield/features/placement.ts +149 -0
  23. package/src/components/textfield/features/prefix-text.ts +107 -0
  24. package/src/components/textfield/features/suffix-text.ts +100 -0
  25. package/src/components/textfield/features/supporting-text.ts +113 -0
  26. package/src/components/textfield/features/trailing-icon.ts +108 -0
  27. package/src/components/textfield/textfield.ts +51 -15
  28. package/src/components/textfield/types.ts +70 -0
  29. package/src/core/collection/adapters/base.ts +62 -0
  30. package/src/core/collection/collection.ts +300 -0
  31. package/src/core/collection/index.ts +57 -0
  32. package/src/core/collection/list-manager.ts +333 -0
  33. package/src/core/dom/classes.ts +81 -9
  34. package/src/core/dom/create.ts +30 -19
  35. package/src/core/layout/README.md +531 -166
  36. package/src/core/layout/array.ts +3 -4
  37. package/src/core/layout/config.ts +193 -0
  38. package/src/core/layout/create.ts +1 -2
  39. package/src/core/layout/index.ts +12 -2
  40. package/src/core/layout/object.ts +2 -3
  41. package/src/core/layout/processor.ts +60 -12
  42. package/src/core/layout/result.ts +1 -2
  43. package/src/core/layout/types.ts +105 -50
  44. package/src/core/layout/utils.ts +69 -61
  45. package/src/index.ts +6 -2
  46. package/src/styles/abstract/_variables.scss +18 -0
  47. package/src/styles/components/_button.scss +21 -5
  48. package/src/styles/components/{_chip.scss → _chips.scss} +118 -4
  49. package/src/styles/components/_menu.scss +109 -18
  50. package/src/styles/components/_select.scss +265 -0
  51. package/src/styles/components/_textfield.scss +233 -42
  52. package/src/styles/main.scss +24 -23
  53. package/src/styles/utilities/_layout.scss +665 -0
  54. package/src/components/menu/features/items-manager.ts +0 -457
  55. package/src/components/menu/features/keyboard-navigation.ts +0 -133
  56. package/src/components/menu/features/positioning.ts +0 -127
  57. package/src/components/menu/features/visibility.ts +0 -230
  58. package/src/components/menu/menu-item.ts +0 -86
  59. package/src/components/menu/utils.ts +0 -67
  60. package/src/components/textfield/features.ts +0 -322
  61. package/src/core/collection/adapters/base.js +0 -26
  62. package/src/core/collection/collection.js +0 -259
  63. package/src/core/collection/list-manager.js +0 -157
  64. /package/src/core/collection/adapters/{route.js → route.ts} +0 -0
  65. /package/src/{core/build → styles/utilities}/_ripple.scss +0 -0
@@ -0,0 +1,1167 @@
1
+ // src/components/menu/features/controller.ts
2
+
3
+ import { MenuConfig, MenuContent, MenuItem, MenuDivider, MenuEvent, MenuSelectEvent } from '../types';
4
+ import { createPositioner } from './position';
5
+
6
+ /**
7
+ * Adds controller functionality to the menu component
8
+ * Manages state, rendering, positioning, and event handling
9
+ *
10
+ * @param config - Menu configuration
11
+ * @returns Component enhancer with menu controller functionality
12
+ */
13
+ export const withController = (config: MenuConfig) => component => {
14
+ if (!component.element) {
15
+ console.warn('Cannot initialize menu controller: missing element');
16
+ return component;
17
+ }
18
+
19
+ // Initialize state
20
+ const state = {
21
+ visible: config.visible || false,
22
+ items: config.items || [],
23
+ position: config.position,
24
+ activeSubmenu: null as HTMLElement,
25
+ activeSubmenuItem: null as HTMLElement,
26
+ activeItemIndex: -1,
27
+ submenuLevel: 0, // Track nesting level of submenus
28
+ activeSubmenus: [] as Array<{
29
+ element: HTMLElement,
30
+ menuItem: HTMLElement,
31
+ level: number,
32
+ isOpening: boolean // Track if submenu is in opening transition
33
+ }>,
34
+ submenuTimer: null,
35
+ hoverIntent: {
36
+ timer: null,
37
+ activeItem: null
38
+ },
39
+ component
40
+ };
41
+
42
+ // Create positioner
43
+ const positioner = createPositioner(component, config);
44
+
45
+ // Create event helpers
46
+ const eventHelpers = {
47
+ triggerEvent(eventName: string, data: any = {}, originalEvent?: Event) {
48
+ const eventData = {
49
+ menu: state.component,
50
+ ...data,
51
+ originalEvent,
52
+ preventDefault: () => { eventData.defaultPrevented = true; },
53
+ defaultPrevented: false
54
+ };
55
+
56
+ component.emit(eventName, eventData);
57
+ return eventData;
58
+ }
59
+ };
60
+
61
+ /**
62
+ * Gets the anchor element from config
63
+ */
64
+ const getAnchorElement = (): HTMLElement => {
65
+ // First try to get the resolved anchor from the anchor feature
66
+ if (component.anchor && typeof component.anchor.getAnchor === 'function') {
67
+ return component.anchor.getAnchor();
68
+ }
69
+
70
+ // Fall back to config anchor for initial positioning
71
+ const { anchor } = config;
72
+
73
+ if (typeof anchor === 'string') {
74
+ const element = document.querySelector(anchor);
75
+ if (!element) {
76
+ console.warn(`Menu anchor not found: ${anchor}`);
77
+ return null;
78
+ }
79
+ return element as HTMLElement;
80
+ }
81
+
82
+ // Handle component with element property
83
+ if (typeof anchor === 'object' && anchor !== null && 'element' in anchor) {
84
+ return anchor.element;
85
+ }
86
+
87
+ // Handle direct HTML element
88
+ return anchor as HTMLElement;
89
+ };
90
+
91
+ /**
92
+ * Creates a DOM element for a menu item
93
+ */
94
+ const createMenuItem = (item: MenuItem, index: number): HTMLElement => {
95
+ const itemElement = document.createElement('li');
96
+ const itemClass = `${component.getClass('menu-item')}`;
97
+
98
+ itemElement.className = itemClass;
99
+ itemElement.setAttribute('role', 'menuitem');
100
+ itemElement.setAttribute('tabindex', '-1'); // Set to -1 by default, will update when needed
101
+ itemElement.setAttribute('data-id', item.id);
102
+ itemElement.setAttribute('data-index', index.toString());
103
+
104
+ if (item.disabled) {
105
+ itemElement.classList.add(`${itemClass}--disabled`);
106
+ itemElement.setAttribute('aria-disabled', 'true');
107
+ } else {
108
+ itemElement.setAttribute('aria-disabled', 'false');
109
+ }
110
+
111
+ if (item.hasSubmenu) {
112
+ itemElement.classList.add(`${itemClass}--submenu`);
113
+ itemElement.setAttribute('aria-haspopup', 'true');
114
+ itemElement.setAttribute('aria-expanded', 'false');
115
+ }
116
+
117
+ // Create content container for flexible layout
118
+ const contentContainer = document.createElement('span');
119
+ contentContainer.className = `${component.getClass('menu-item-content')}`;
120
+
121
+ // Add icon if provided
122
+ if (item.icon) {
123
+ const iconElement = document.createElement('span');
124
+ iconElement.className = `${component.getClass('menu-item-icon')}`;
125
+ iconElement.innerHTML = item.icon;
126
+ contentContainer.appendChild(iconElement);
127
+ }
128
+
129
+ // Add text
130
+ const textElement = document.createElement('span');
131
+ textElement.className = `${component.getClass('menu-item-text')}`;
132
+ textElement.textContent = item.text;
133
+ contentContainer.appendChild(textElement);
134
+
135
+ // Add shortcut if provided
136
+ if (item.shortcut) {
137
+ const shortcutElement = document.createElement('span');
138
+ shortcutElement.className = `${component.getClass('menu-item-shortcut')}`;
139
+ shortcutElement.textContent = item.shortcut;
140
+ contentContainer.appendChild(shortcutElement);
141
+ }
142
+
143
+ itemElement.appendChild(contentContainer);
144
+
145
+ // Add event listeners
146
+ if (!item.disabled) {
147
+ // Mouse events
148
+ itemElement.addEventListener('click', (e) => handleItemClick(e, item, index));
149
+
150
+ // Focus and blur events for proper focus styling
151
+ itemElement.addEventListener('focus', () => {
152
+ state.activeItemIndex = index;
153
+ });
154
+
155
+ // Additional keyboard event handler for accessibility
156
+ itemElement.addEventListener('keydown', (e) => {
157
+ if (e.key === 'Enter' || e.key === ' ') {
158
+ e.preventDefault();
159
+ handleItemClick(e, item, index);
160
+ }
161
+ });
162
+
163
+ if (item.hasSubmenu && config.openSubmenuOnHover) {
164
+ itemElement.addEventListener('mouseenter', () => handleSubmenuHover(item, index, itemElement));
165
+ itemElement.addEventListener('mouseleave', handleSubmenuLeave);
166
+ }
167
+ }
168
+
169
+ return itemElement;
170
+ };
171
+
172
+ /**
173
+ * Creates a DOM element for a menu divider
174
+ */
175
+ const createDivider = (divider: MenuDivider, index: number): HTMLElement => {
176
+ const dividerElement = document.createElement('li');
177
+ dividerElement.className = `${component.getClass('menu-divider')}`;
178
+ dividerElement.setAttribute('role', 'separator');
179
+ dividerElement.setAttribute('data-index', index.toString());
180
+
181
+ if (divider.id) {
182
+ dividerElement.setAttribute('id', divider.id);
183
+ }
184
+
185
+ return dividerElement;
186
+ };
187
+
188
+ /**
189
+ * Renders the menu items
190
+ */
191
+ const renderMenuItems = (): void => {
192
+ const menuList = document.createElement('ul');
193
+ menuList.className = `${component.getClass('menu-list')}`;
194
+ menuList.setAttribute('role', 'menu');
195
+
196
+ // Create items
197
+ state.items.forEach((item, index) => {
198
+ if ('type' in item && item.type === 'divider') {
199
+ menuList.appendChild(createDivider(item, index));
200
+ } else {
201
+ menuList.appendChild(createMenuItem(item as MenuItem, index));
202
+ }
203
+ });
204
+
205
+ // Clear and append
206
+ component.element.innerHTML = '';
207
+ component.element.appendChild(menuList);
208
+ };
209
+
210
+ /**
211
+ * Clean up hover intent timer
212
+ */
213
+ const clearHoverIntent = () => {
214
+ if (state.hoverIntent.timer) {
215
+ clearTimeout(state.hoverIntent.timer);
216
+ state.hoverIntent.timer = null;
217
+ state.hoverIntent.activeItem = null;
218
+ }
219
+ };
220
+
221
+ /**
222
+ * Sets focus appropriately based on interaction type
223
+ * For keyboard interactions, focuses the first item
224
+ * For mouse interactions, makes the menu container focusable but doesn't auto-focus
225
+ *
226
+ * @param {'keyboard'|'mouse'} interactionType - Type of interaction that opened the menu
227
+ */
228
+ const handleFocus = (interactionType: 'keyboard' | 'mouse'): void => {
229
+ // Reset active item index
230
+ state.activeItemIndex = -1;
231
+
232
+ if (interactionType === 'keyboard') {
233
+ // Find all focusable items
234
+ const items = Array.from(
235
+ component.element.querySelectorAll(`.${component.getClass('menu-item')}:not(.${component.getClass('menu-item--disabled')})`)
236
+ ) as HTMLElement[];
237
+
238
+ if (items.length > 0) {
239
+ // Focus the first item for keyboard navigation
240
+ items[0].setAttribute('tabindex', '0');
241
+ items[0].focus();
242
+ state.activeItemIndex = 0;
243
+ } else {
244
+ // If no items, focus the menu itself
245
+ component.element.setAttribute('tabindex', '0');
246
+ component.element.focus();
247
+ }
248
+ } else {
249
+ // For mouse interaction, make the menu focusable but don't auto-focus
250
+ component.element.setAttribute('tabindex', '-1');
251
+ }
252
+ };
253
+
254
+ /**
255
+ * Handles click on a menu item
256
+ */
257
+ const handleItemClick = (e: MouseEvent, item: MenuItem, index: number): void => {
258
+ e.preventDefault();
259
+ e.stopPropagation();
260
+
261
+ // Don't process if disabled
262
+ if (item.disabled) return;
263
+
264
+ if (item.hasSubmenu) {
265
+ handleSubmenuClick(item, index, e.currentTarget as HTMLElement);
266
+ return;
267
+ }
268
+
269
+ // Trigger select event
270
+ const selectEvent = eventHelpers.triggerEvent('select', {
271
+ item,
272
+ itemId: item.id,
273
+ itemData: item.data
274
+ }, e) as MenuSelectEvent;
275
+
276
+ // Close menu if needed
277
+ if (config.closeOnSelect && !selectEvent.defaultPrevented) {
278
+ closeMenu(e);
279
+ }
280
+ };
281
+
282
+ /**
283
+ * Handles click on a submenu item
284
+ */
285
+ const handleSubmenuClick = (item: MenuItem, index: number, itemElement: HTMLElement, viaKeyboard = false): void => {
286
+ if (!item.submenu || !item.hasSubmenu) return;
287
+
288
+ // Check if the submenu is already open
289
+ const isOpen = itemElement.getAttribute('aria-expanded') === 'true';
290
+
291
+ // Find if any submenu is currently in opening transition
292
+ const anySubmenuTransitioning = state.activeSubmenus.some(s => s.isOpening);
293
+
294
+ // Completely ignore clicks during any submenu transition
295
+ if (anySubmenuTransitioning) {
296
+ return;
297
+ }
298
+
299
+ if (isOpen) {
300
+ // Close submenu - only if fully open
301
+ // Find the closest submenu level
302
+ const currentLevel = parseInt(
303
+ itemElement.closest(`.${component.getClass('menu--submenu')}`)?.getAttribute('data-level') || '0',
304
+ 10
305
+ );
306
+
307
+ // Close this level + 1 and deeper
308
+ closeSubmenuAtLevel(currentLevel + 1);
309
+
310
+ // Reset expanded state
311
+ itemElement.setAttribute('aria-expanded', 'false');
312
+ } else {
313
+ // Open new submenu
314
+ openSubmenu(item, index, itemElement, viaKeyboard);
315
+ }
316
+ };
317
+
318
+ /**
319
+ * Handles hover on a submenu item
320
+ */
321
+ const handleSubmenuHover = (item: MenuItem, index: number, itemElement: HTMLElement): void => {
322
+ if (!config.openSubmenuOnHover || !item.hasSubmenu) return;
323
+
324
+ // Clear any existing timers
325
+ clearHoverIntent();
326
+ clearSubmenuTimer();
327
+
328
+ // Set hover intent
329
+ state.hoverIntent.activeItem = itemElement;
330
+ state.hoverIntent.timer = setTimeout(() => {
331
+ const isCurrentlyHovered = itemElement.matches(':hover');
332
+ if (isCurrentlyHovered) {
333
+ // Only close and reopen if this is a different submenu item
334
+ if (state.activeSubmenuItem !== itemElement) {
335
+ openSubmenu(item, index, itemElement);
336
+ }
337
+ }
338
+ state.hoverIntent.timer = null;
339
+ }, 100);
340
+ };
341
+
342
+ /**
343
+ * Handles mouse leave from submenu
344
+ */
345
+ const handleSubmenuLeave = (e: MouseEvent): void => {
346
+ // Clear hover intent
347
+ clearHoverIntent();
348
+
349
+ // Don't close immediately to allow moving to submenu
350
+ clearSubmenuTimer();
351
+
352
+ // Set a timer to close the submenu if not re-entered
353
+ state.submenuTimer = setTimeout(() => {
354
+ // Check if mouse is over the submenu or the parent menu item
355
+ const submenuElement = state.activeSubmenu;
356
+ const menuItemElement = state.activeSubmenuItem;
357
+
358
+ if (submenuElement && menuItemElement) {
359
+ const overSubmenu = submenuElement.matches(':hover');
360
+ const overMenuItem = menuItemElement.matches(':hover');
361
+
362
+ if (!overSubmenu && !overMenuItem) {
363
+ closeSubmenuAtLevel(state.submenuLevel);
364
+ }
365
+ }
366
+
367
+ state.submenuTimer = null;
368
+ }, 300);
369
+ };
370
+
371
+ /**
372
+ * Opens a submenu with proper animation and positioning
373
+ */
374
+ const openSubmenu = (item: MenuItem, index: number, itemElement: HTMLElement, viaKeyboard = false): void => {
375
+ if (!item.submenu || !item.hasSubmenu) return;
376
+
377
+ // Get current level of the submenu we're opening
378
+ const currentLevel = itemElement.closest(`.${component.getClass('menu--submenu')}`)
379
+ ? parseInt(itemElement.closest(`.${component.getClass('menu--submenu')}`).getAttribute('data-level') || '0', 10) + 1
380
+ : 1;
381
+
382
+ // Close any deeper level submenus first, preserving the current level
383
+ closeSubmenuAtLevel(currentLevel);
384
+
385
+ // Check if this submenu is already in opening state - if so, do nothing
386
+ const existingSubmenuIndex = state.activeSubmenus.findIndex(
387
+ s => s.menuItem === itemElement && s.isOpening
388
+ );
389
+ if (existingSubmenuIndex >= 0) {
390
+ return; // Already opening this submenu, don't restart the process
391
+ }
392
+
393
+ // Set expanded state
394
+ itemElement.setAttribute('aria-expanded', 'true');
395
+
396
+ // Create submenu element with proper classes and attributes
397
+ const submenuElement = document.createElement('div');
398
+ submenuElement.className = `${component.getClass('menu')} ${component.getClass('menu--submenu')}`;
399
+ submenuElement.setAttribute('role', 'menu');
400
+ submenuElement.setAttribute('tabindex', '-1');
401
+ submenuElement.setAttribute('data-level', currentLevel.toString());
402
+ submenuElement.setAttribute('data-parent-item', item.id);
403
+
404
+ // Increase z-index for each level of submenu
405
+ submenuElement.style.zIndex = `${1000 + (currentLevel * 10)}`;
406
+
407
+ // Create submenu list
408
+ const submenuList = document.createElement('ul');
409
+ submenuList.className = `${component.getClass('menu-list')}`;
410
+
411
+ // Create submenu items
412
+ const submenuItems = [];
413
+ item.submenu.forEach((subitem, subindex) => {
414
+ if ('type' in subitem && subitem.type === 'divider') {
415
+ submenuList.appendChild(createDivider(subitem, subindex));
416
+ } else {
417
+ const subitemElement = createMenuItem(subitem as MenuItem, subindex);
418
+ submenuList.appendChild(subitemElement);
419
+ if (!(subitem as MenuItem).disabled) {
420
+ submenuItems.push(subitemElement);
421
+ }
422
+ }
423
+ });
424
+
425
+ submenuElement.appendChild(submenuList);
426
+
427
+ // Add to DOM to enable measurement and transitions
428
+ document.body.appendChild(submenuElement);
429
+
430
+ // Position the submenu using our positioner with the current nesting level
431
+ positioner.positionSubmenu(submenuElement, itemElement, currentLevel);
432
+
433
+ // Setup keyboard navigation for submenu
434
+ submenuElement.addEventListener('keydown', handleMenuKeydown);
435
+
436
+ // Add mouseenter event to prevent closing
437
+ submenuElement.addEventListener('mouseenter', () => {
438
+ clearSubmenuTimer();
439
+ });
440
+
441
+ // Add mouseleave event to handle closing
442
+ submenuElement.addEventListener('mouseleave', (e) => {
443
+ handleSubmenuLeave(e);
444
+ });
445
+
446
+ // Setup submenu event handlers for nested submenus
447
+ const setupNestedSubmenuHandlers = (parent: HTMLElement) => {
448
+ const submenuItems = parent.querySelectorAll(`.${component.getClass('menu-item--submenu')}`) as NodeListOf<HTMLElement>;
449
+
450
+ submenuItems.forEach((menuItem) => {
451
+ const itemIndex = parseInt(menuItem.getAttribute('data-index'), 10);
452
+ const menuItemData = item.submenu[itemIndex] as MenuItem;
453
+
454
+ if (menuItemData && menuItemData.hasSubmenu) {
455
+ // Add hover handler for nested submenus
456
+ if (config.openSubmenuOnHover) {
457
+ menuItem.addEventListener('mouseenter', () => {
458
+ handleNestedSubmenuHover(menuItemData, itemIndex, menuItem);
459
+ });
460
+ menuItem.addEventListener('mouseleave', handleSubmenuLeave);
461
+ }
462
+
463
+ // Add click handler for nested submenus
464
+ menuItem.addEventListener('click', (e) => {
465
+ e.preventDefault();
466
+ e.stopPropagation();
467
+ handleNestedSubmenuClick(menuItemData, itemIndex, menuItem, false);
468
+ });
469
+ }
470
+ });
471
+ };
472
+
473
+ // Setup handlers for any nested submenus
474
+ setupNestedSubmenuHandlers(submenuElement);
475
+
476
+ // Update state with active submenu
477
+ state.activeSubmenu = submenuElement;
478
+ state.activeSubmenuItem = itemElement;
479
+
480
+ // Add to active submenus array to maintain hierarchy
481
+ state.activeSubmenus.push({
482
+ element: submenuElement,
483
+ menuItem: itemElement,
484
+ level: currentLevel,
485
+ isOpening: true // Mark as in opening transition
486
+ });
487
+
488
+ // Update submenu level
489
+ state.submenuLevel = currentLevel;
490
+
491
+ // Add document events for this submenu
492
+ document.addEventListener('click', handleDocumentClickForSubmenu);
493
+ window.addEventListener('resize', handleWindowResizeForSubmenu, { passive: true });
494
+ window.addEventListener('scroll', handleWindowScrollForSubmenu, { passive: true });
495
+
496
+ // Make visible with animation
497
+ requestAnimationFrame(() => {
498
+ submenuElement.classList.add(`${component.getClass('menu--visible')}`);
499
+
500
+ // Wait for transition to complete before marking as fully opened
501
+ // This should match your CSS transition duration
502
+ setTimeout(() => {
503
+ // Find this submenu in the active submenus array and update its state
504
+ const index = state.activeSubmenus.findIndex(s => s.element === submenuElement);
505
+ if (index !== -1) {
506
+ state.activeSubmenus[index].isOpening = false;
507
+ }
508
+ }, 300); // Adjust to match your transition duration
509
+
510
+ // If opened via keyboard, focus the first item in the submenu
511
+ if (viaKeyboard && submenuItems.length > 0) {
512
+ setTimeout(() => {
513
+ submenuItems[0].focus();
514
+ }, 50);
515
+ }
516
+ });
517
+ };
518
+
519
+ /**
520
+ * Handles hover on a nested submenu item
521
+ */
522
+ const handleNestedSubmenuHover = (item: MenuItem, index: number, itemElement: HTMLElement): void => {
523
+ if (!config.openSubmenuOnHover || !item.hasSubmenu) return;
524
+
525
+ // Clear any existing timers
526
+ clearHoverIntent();
527
+ clearSubmenuTimer();
528
+
529
+ // Set hover intent with a slightly longer delay for nested menus
530
+ state.hoverIntent.activeItem = itemElement;
531
+ state.hoverIntent.timer = setTimeout(() => {
532
+ const isCurrentlyHovered = itemElement.matches(':hover');
533
+ if (isCurrentlyHovered) {
534
+ // Find the closest submenu level of this item
535
+ const currentLevel = parseInt(
536
+ itemElement.closest(`.${component.getClass('menu--submenu')}`)?.getAttribute('data-level') || '1',
537
+ 10
538
+ );
539
+
540
+ // Open the nested submenu (will handle closing deeper levels properly)
541
+ handleNestedSubmenuClick(item, index, itemElement, false);
542
+ }
543
+ state.hoverIntent.timer = null;
544
+ }, 120); // Slightly longer delay for nested submenus
545
+ };
546
+
547
+ /**
548
+ * Handles click on a nested submenu item
549
+ */
550
+ const handleNestedSubmenuClick = (item: MenuItem, index: number, itemElement: HTMLElement, viaKeyboard = false): void => {
551
+ if (!item.submenu || !item.hasSubmenu) return;
552
+
553
+ // Check if the submenu is already open
554
+ const isOpen = itemElement.getAttribute('aria-expanded') === 'true';
555
+
556
+ // Find if any submenu is currently in opening transition
557
+ const anySubmenuTransitioning = state.activeSubmenus.some(s => s.isOpening);
558
+
559
+ // Completely ignore clicks during any submenu transition
560
+ if (anySubmenuTransitioning) {
561
+ return;
562
+ }
563
+
564
+ if (isOpen) {
565
+ // Find the closest submenu level
566
+ const currentLevel = parseInt(
567
+ itemElement.closest(`.${component.getClass('menu--submenu')}`)?.getAttribute('data-level') || '1',
568
+ 10
569
+ );
570
+
571
+ // Close submenus at and deeper than the next level
572
+ closeSubmenuAtLevel(currentLevel + 1);
573
+ } else {
574
+ // Open the nested submenu
575
+ openSubmenu(item, index, itemElement, viaKeyboard);
576
+ }
577
+ };
578
+
579
+ /**
580
+ * Clear submenu close timer
581
+ */
582
+ const clearSubmenuTimer = () => {
583
+ if (state.submenuTimer) {
584
+ clearTimeout(state.submenuTimer);
585
+ state.submenuTimer = null;
586
+ }
587
+ };
588
+
589
+ /**
590
+ * Closes submenus at or deeper than the specified level
591
+ * @param level - The level to start closing from
592
+ */
593
+ const closeSubmenuAtLevel = (level: number): void => {
594
+ // Clear any hover intent or submenu timers
595
+ clearHoverIntent();
596
+ clearSubmenuTimer();
597
+
598
+ // Find submenus at or deeper than the specified level
599
+ const submenusCopy = [...state.activeSubmenus];
600
+ const submenuIndicesToRemove = [];
601
+
602
+ // Identify which submenus to remove, working from deepest level first
603
+ for (let i = submenusCopy.length - 1; i >= 0; i--) {
604
+ if (submenusCopy[i].level >= level) {
605
+ const submenuToClose = submenusCopy[i];
606
+
607
+ // Set aria-expanded attribute to false on the parent menu item
608
+ if (submenuToClose.menuItem) {
609
+ submenuToClose.menuItem.setAttribute('aria-expanded', 'false');
610
+ }
611
+
612
+ // Hide with animation
613
+ submenuToClose.element.classList.remove(`${component.getClass('menu--visible')}`);
614
+
615
+ // Schedule for removal
616
+ setTimeout(() => {
617
+ if (submenuToClose.element.parentNode) {
618
+ submenuToClose.element.parentNode.removeChild(submenuToClose.element);
619
+ }
620
+ }, 200);
621
+
622
+ // Mark for removal from state
623
+ submenuIndicesToRemove.push(i);
624
+ }
625
+ }
626
+
627
+ // Remove the closed submenus from state
628
+ submenuIndicesToRemove.forEach(index => {
629
+ state.activeSubmenus.splice(index, 1);
630
+ });
631
+
632
+ // Update active submenu references based on what's left
633
+ if (state.activeSubmenus.length > 0) {
634
+ const deepestRemaining = state.activeSubmenus[state.activeSubmenus.length - 1];
635
+ state.activeSubmenu = deepestRemaining.element;
636
+ state.activeSubmenuItem = deepestRemaining.menuItem;
637
+ state.submenuLevel = deepestRemaining.level;
638
+ } else {
639
+ state.activeSubmenu = null;
640
+ state.activeSubmenuItem = null;
641
+ state.submenuLevel = 0;
642
+ }
643
+ };
644
+
645
+ /**
646
+ * Closes all submenus
647
+ */
648
+ const closeSubmenu = (): void => {
649
+ // Clear timers
650
+ clearHoverIntent();
651
+ clearSubmenuTimer();
652
+
653
+ if (state.activeSubmenus.length === 0) return;
654
+
655
+ // Close all active submenus
656
+ [...state.activeSubmenus].forEach(submenu => {
657
+ // Remove expanded state from parent item
658
+ if (submenu.menuItem) {
659
+ submenu.menuItem.setAttribute('aria-expanded', 'false');
660
+ }
661
+
662
+ // Remove submenu element with animation
663
+ submenu.element.classList.remove(`${component.getClass('menu--visible')}`);
664
+
665
+ // Remove after animation
666
+ setTimeout(() => {
667
+ if (submenu.element.parentNode) {
668
+ submenu.element.parentNode.removeChild(submenu.element);
669
+ }
670
+ }, 200);
671
+ });
672
+
673
+ // Clear state
674
+ state.activeSubmenu = null;
675
+ state.activeSubmenuItem = null;
676
+ state.activeSubmenus = [];
677
+ state.submenuLevel = 0;
678
+
679
+ // Remove document events
680
+ document.removeEventListener('click', handleDocumentClickForSubmenu);
681
+ window.removeEventListener('resize', handleWindowResizeForSubmenu);
682
+ window.removeEventListener('scroll', handleWindowScrollForSubmenu);
683
+ };
684
+
685
+ /**
686
+ * Handles document click for submenu
687
+ */
688
+ const handleDocumentClickForSubmenu = (e: MouseEvent): void => {
689
+ if (!state.activeSubmenu) return;
690
+
691
+ const submenuElement = state.activeSubmenu;
692
+ const menuItemElement = state.activeSubmenuItem;
693
+
694
+ // Check if click was inside submenu or parent menu item
695
+ if (submenuElement.contains(e.target as Node) ||
696
+ (menuItemElement && menuItemElement.contains(e.target as Node))) {
697
+ return;
698
+ }
699
+
700
+ // Close submenu if clicked outside
701
+ closeSubmenu();
702
+ };
703
+
704
+ /**
705
+ * Handles window resize for submenu
706
+ */
707
+ const handleWindowResizeForSubmenu = (): void => {
708
+ // Reposition open submenu on resize
709
+ if (state.activeSubmenu && state.activeSubmenuItem) {
710
+ positioner.positionSubmenu(state.activeSubmenu, state.activeSubmenuItem, state.submenuLevel);
711
+ }
712
+ };
713
+
714
+ /**
715
+ * Handles window scroll for submenu
716
+ * Repositions the submenu to stay attached to its parent during scrolling
717
+ */
718
+ const handleWindowScrollForSubmenu = (): void => {
719
+ // Use requestAnimationFrame to optimize scroll performance
720
+ window.requestAnimationFrame(() => {
721
+ // Only reposition if we have an active submenu
722
+ if (state.activeSubmenu && state.activeSubmenuItem) {
723
+ positioner.positionSubmenu(state.activeSubmenu, state.activeSubmenuItem, state.submenuLevel);
724
+ }
725
+ });
726
+ };
727
+
728
+ /**
729
+ * Opens the menu
730
+ * @param {Event} [event] - Optional event that triggered the open
731
+ * @param {'mouse'|'keyboard'} [interactionType='mouse'] - Type of interaction that triggered the open
732
+ */
733
+ const openMenu = (event?: Event, interactionType: 'mouse' | 'keyboard' = 'mouse'): void => {
734
+ if (state.visible) return;
735
+
736
+ // Update state
737
+ state.visible = true;
738
+
739
+ // Step 1: Add the menu to the DOM if it's not already there with initial hidden state
740
+ if (!component.element.parentNode) {
741
+ // Apply explicit initial styling to ensure it doesn't flash
742
+ component.element.classList.remove(`${component.getClass('menu--visible')}`);
743
+ component.element.setAttribute('aria-hidden', 'true');
744
+ component.element.style.transform = 'scaleY(0)';
745
+ component.element.style.opacity = '0';
746
+
747
+ // Add to DOM
748
+ document.body.appendChild(component.element);
749
+ }
750
+
751
+ // Step 2: Position the menu (will be invisible)
752
+ const anchorElement = getAnchorElement();
753
+ if (anchorElement) {
754
+ positioner.positionMenu(anchorElement);
755
+ }
756
+
757
+ // Step 3: Use a small delay to ensure DOM operations are complete
758
+ setTimeout(() => {
759
+ // Set attributes for accessibility
760
+ component.element.setAttribute('aria-hidden', 'false');
761
+
762
+ // Remove the inline styles we added
763
+ component.element.style.transform = '';
764
+ component.element.style.opacity = '';
765
+
766
+ // Force a reflow before adding the visible class
767
+ void component.element.getBoundingClientRect();
768
+
769
+ // Add visible class to start the CSS transition
770
+ component.element.classList.add(`${component.getClass('menu--visible')}`);
771
+
772
+ // Step 4: Focus based on interaction type (after animation starts)
773
+ setTimeout(() => {
774
+ handleFocus(interactionType);
775
+ }, 100);
776
+ }, 20); // Short delay for browser to process
777
+
778
+ // Add document events
779
+ if (config.closeOnClickOutside) {
780
+ document.addEventListener('click', handleDocumentClick);
781
+ }
782
+ if (config.closeOnEscape) {
783
+ document.addEventListener('keydown', handleDocumentKeydown);
784
+ }
785
+ window.addEventListener('resize', handleWindowResize, { passive: true });
786
+ window.addEventListener('scroll', handleWindowScroll, { passive: true });
787
+
788
+ // Trigger event
789
+ eventHelpers.triggerEvent('open', {}, event);
790
+ };
791
+
792
+ /**
793
+ * Closes the menu
794
+ */
795
+ const closeMenu = (event?: Event): void => {
796
+ if (!state.visible) return;
797
+
798
+ // Close any open submenu first
799
+ closeSubmenu();
800
+
801
+ // Update state
802
+ state.visible = false;
803
+
804
+ // Set attributes
805
+ component.element.setAttribute('aria-hidden', 'true');
806
+ component.element.classList.remove(`${component.getClass('menu--visible')}`);
807
+
808
+ // Remove document events
809
+ document.removeEventListener('click', handleDocumentClick);
810
+ document.removeEventListener('keydown', handleDocumentKeydown);
811
+ window.removeEventListener('resize', handleWindowResize);
812
+ window.removeEventListener('scroll', handleWindowScroll);
813
+
814
+ // Trigger event
815
+ eventHelpers.triggerEvent('close', {}, event);
816
+
817
+ // Remove from DOM after animation completes
818
+ setTimeout(() => {
819
+ if (component.element.parentNode && !state.visible) {
820
+ component.element.parentNode.removeChild(component.element);
821
+ }
822
+ }, 300); // Match the animation duration in CSS
823
+ };
824
+
825
+ /**
826
+ * Toggles the menu
827
+ */
828
+ const toggleMenu = (event?: Event): void => {
829
+ if (state.visible) {
830
+ closeMenu(event);
831
+ } else {
832
+ openMenu(event);
833
+ }
834
+ };
835
+
836
+ /**
837
+ * Handles document click
838
+ */
839
+ const handleDocumentClick = (e: MouseEvent): void => {
840
+ // Don't close if clicked inside menu
841
+ if (component.element.contains(e.target as Node)) {
842
+ return;
843
+ }
844
+
845
+ // Check if clicked on anchor element
846
+ const anchor = getAnchorElement();
847
+ if (anchor && anchor.contains(e.target as Node)) {
848
+ return;
849
+ }
850
+
851
+ // Close menu
852
+ closeMenu(e);
853
+ };
854
+
855
+ /**
856
+ * Handles document keydown
857
+ */
858
+ const handleDocumentKeydown = (e: KeyboardEvent): void => {
859
+ if (e.key === 'Escape') {
860
+ closeMenu(e);
861
+ }
862
+ };
863
+
864
+ /**
865
+ * Handles window resize
866
+ */
867
+ const handleWindowResize = (): void => {
868
+ if (state.visible) {
869
+ const anchorElement = getAnchorElement();
870
+ if (anchorElement) {
871
+ positioner.positionMenu(anchorElement);
872
+ }
873
+ }
874
+ };
875
+
876
+ /**
877
+ * Handles window scroll
878
+ * Repositions the menu to stay attached to its anchor during scrolling
879
+ */
880
+ const handleWindowScroll = (): void => {
881
+ if (state.visible) {
882
+ // Use requestAnimationFrame to optimize scroll performance
883
+ window.requestAnimationFrame(() => {
884
+ // Reposition the main menu to stay attached to anchor when scrolling
885
+ const anchorElement = getAnchorElement();
886
+ if (anchorElement) {
887
+ positioner.positionMenu(anchorElement);
888
+ }
889
+
890
+ // Also reposition any open submenu relative to its parent menu item
891
+ if (state.activeSubmenu && state.activeSubmenuItem) {
892
+ positioner.positionSubmenu(state.activeSubmenu, state.activeSubmenuItem, state.submenuLevel);
893
+ }
894
+ });
895
+ }
896
+ };
897
+
898
+ /**
899
+ * Handles keydown events on the menu or submenu
900
+ */
901
+ const handleMenuKeydown = (e: KeyboardEvent): void => {
902
+ // Determine if this event is from the main menu or a submenu
903
+ const isSubmenu = state.activeSubmenu && state.activeSubmenu.contains(e.target as Node);
904
+
905
+ // Get the appropriate menu element
906
+ const menuElement = isSubmenu ? state.activeSubmenu : component.element;
907
+
908
+ // Get all non-disabled menu items from the current menu
909
+ const items = Array.from(menuElement.querySelectorAll(
910
+ `.${component.getClass('menu-item')}:not(.${component.getClass('menu-item--disabled')})`
911
+ )) as HTMLElement[];
912
+
913
+ if (items.length === 0) return;
914
+
915
+ // Get the currently focused item index
916
+ let focusedItemIndex = -1;
917
+ const focusedElement = menuElement.querySelector(':focus') as HTMLElement;
918
+ if (focusedElement && focusedElement.classList.contains(component.getClass('menu-item'))) {
919
+ focusedItemIndex = items.indexOf(focusedElement);
920
+ }
921
+
922
+ switch (e.key) {
923
+ case 'ArrowDown':
924
+ case 'Down':
925
+ e.preventDefault();
926
+ // If no item is active, select the first one
927
+ if (focusedItemIndex < 0) {
928
+ items[0].focus();
929
+ } else if (focusedItemIndex < items.length - 1) {
930
+ items[focusedItemIndex + 1].focus();
931
+ } else {
932
+ items[0].focus();
933
+ }
934
+ break;
935
+
936
+ case 'ArrowUp':
937
+ case 'Up':
938
+ e.preventDefault();
939
+ // If no item is active, select the last one
940
+ if (focusedItemIndex < 0) {
941
+ items[items.length - 1].focus();
942
+ } else if (focusedItemIndex > 0) {
943
+ items[focusedItemIndex - 1].focus();
944
+ } else {
945
+ items[items.length - 1].focus();
946
+ }
947
+ break;
948
+
949
+ case 'Home':
950
+ e.preventDefault();
951
+ items[0].focus();
952
+ break;
953
+
954
+ case 'End':
955
+ e.preventDefault();
956
+ items[items.length - 1].focus();
957
+ break;
958
+
959
+ case 'Enter':
960
+ case ' ':
961
+ e.preventDefault();
962
+ // If an item is focused, click it
963
+ if (focusedItemIndex >= 0) {
964
+ items[focusedItemIndex].click();
965
+ }
966
+ break;
967
+
968
+ case 'ArrowRight':
969
+ case 'Right':
970
+ e.preventDefault();
971
+ // Handle right arrow in different contexts
972
+ if (isSubmenu) {
973
+ // In a submenu, right arrow opens nested submenus
974
+ if (focusedItemIndex >= 0 && items[focusedItemIndex].classList.contains(`${component.getClass('menu-item--submenu')}`)) {
975
+ items[focusedItemIndex].click();
976
+ }
977
+ } else {
978
+ // In main menu, right arrow opens a submenu
979
+ if (focusedItemIndex >= 0 && items[focusedItemIndex].classList.contains(`${component.getClass('menu-item--submenu')}`)) {
980
+ // Get the correct menu item data
981
+ const itemElement = items[focusedItemIndex];
982
+ const itemIndex = parseInt(itemElement.getAttribute('data-index'), 10);
983
+ const itemData = state.items[itemIndex] as MenuItem;
984
+
985
+ // Open submenu via keyboard
986
+ handleSubmenuClick(itemData, itemIndex, itemElement, true);
987
+ }
988
+ }
989
+ break;
990
+
991
+ case 'ArrowLeft':
992
+ case 'Left':
993
+ e.preventDefault();
994
+ // Handle left arrow in different contexts
995
+ if (isSubmenu) {
996
+ // In a submenu, left arrow returns to the parent menu
997
+ if (state.activeSubmenuItem) {
998
+ // Store the reference to the parent item before closing the submenu
999
+ const parentItem = state.activeSubmenuItem;
1000
+
1001
+ // Get the current level
1002
+ const currentLevel = parseInt(
1003
+ menuElement.getAttribute('data-level') || '1',
1004
+ 10
1005
+ );
1006
+
1007
+ // Close this level of submenu
1008
+ closeSubmenuAtLevel(currentLevel);
1009
+
1010
+ // Focus the parent item after closing
1011
+ parentItem.focus();
1012
+ } else {
1013
+ closeSubmenu();
1014
+ }
1015
+ }
1016
+ break;
1017
+
1018
+ case 'Escape':
1019
+ e.preventDefault();
1020
+ if (isSubmenu) {
1021
+ // In a submenu, Escape closes just the submenu
1022
+ if (state.activeSubmenuItem) {
1023
+ // Store the reference to the parent item before closing the submenu
1024
+ const parentItem = state.activeSubmenuItem;
1025
+
1026
+ // Get the current level
1027
+ const currentLevel = parseInt(
1028
+ menuElement.getAttribute('data-level') || '1',
1029
+ 10
1030
+ );
1031
+
1032
+ // Close this level of submenu
1033
+ closeSubmenuAtLevel(currentLevel);
1034
+
1035
+ // Focus the parent item after closing
1036
+ parentItem.focus();
1037
+ } else {
1038
+ closeSubmenu();
1039
+ }
1040
+ } else {
1041
+ // In main menu, Escape closes the entire menu
1042
+ closeMenu(e);
1043
+ }
1044
+ break;
1045
+
1046
+ case 'Tab':
1047
+ // Close the menu when tabbing out
1048
+ if (!isSubmenu) {
1049
+ closeMenu();
1050
+ }
1051
+ break;
1052
+ }
1053
+ };
1054
+
1055
+ /**
1056
+ * Sets up the menu
1057
+ */
1058
+ const initMenu = () => {
1059
+ // Set up menu structure
1060
+ renderMenuItems();
1061
+
1062
+ // Set up keyboard navigation
1063
+ component.element.addEventListener('keydown', handleMenuKeydown);
1064
+
1065
+ // Position if visible
1066
+ if (state.visible) {
1067
+ const anchorElement = getAnchorElement();
1068
+ if (anchorElement) {
1069
+ positioner.positionMenu(anchorElement);
1070
+ }
1071
+
1072
+ // Show immediately
1073
+ component.element.classList.add(`${component.getClass('menu--visible')}`);
1074
+
1075
+ // Set up document events
1076
+ if (config.closeOnClickOutside) {
1077
+ document.addEventListener('click', handleDocumentClick);
1078
+ }
1079
+ if (config.closeOnEscape) {
1080
+ document.addEventListener('keydown', handleDocumentKeydown);
1081
+ }
1082
+ window.addEventListener('resize', handleWindowResize);
1083
+ window.addEventListener('scroll', handleWindowScroll);
1084
+ }
1085
+ };
1086
+
1087
+ // Initialize after DOM is ready
1088
+ setTimeout(initMenu, 0);
1089
+
1090
+ // Register with lifecycle if available
1091
+ if (component.lifecycle) {
1092
+ const originalDestroy = component.lifecycle.destroy || (() => {});
1093
+ component.lifecycle.destroy = () => {
1094
+ // Clean up timers
1095
+ clearHoverIntent();
1096
+ clearSubmenuTimer();
1097
+
1098
+ // Clean up document events
1099
+ document.removeEventListener('click', handleDocumentClick);
1100
+ document.removeEventListener('keydown', handleDocumentKeydown);
1101
+ window.removeEventListener('resize', handleWindowResize);
1102
+ window.removeEventListener('scroll', handleWindowScroll);
1103
+
1104
+ // Clean up submenu events
1105
+ document.removeEventListener('click', handleDocumentClickForSubmenu);
1106
+ window.removeEventListener('resize', handleWindowResizeForSubmenu);
1107
+ window.removeEventListener('scroll', handleWindowScrollForSubmenu);
1108
+
1109
+ // Clean up submenu element
1110
+ if (state.activeSubmenus.length > 0) {
1111
+ state.activeSubmenus.forEach(submenu => {
1112
+ if (submenu.element.parentNode) {
1113
+ submenu.element.parentNode.removeChild(submenu.element);
1114
+ }
1115
+ });
1116
+ }
1117
+
1118
+ originalDestroy();
1119
+ };
1120
+ }
1121
+
1122
+ // Return enhanced component
1123
+ return {
1124
+ ...component,
1125
+ menu: {
1126
+ open: (event) => {
1127
+ openMenu(event);
1128
+ return component;
1129
+ },
1130
+
1131
+ close: (event) => {
1132
+ closeMenu(event);
1133
+ return component;
1134
+ },
1135
+
1136
+ toggle: (event) => {
1137
+ toggleMenu(event);
1138
+ return component;
1139
+ },
1140
+
1141
+ isOpen: () => state.visible,
1142
+
1143
+ setItems: (items) => {
1144
+ state.items = items;
1145
+ renderMenuItems();
1146
+ return component;
1147
+ },
1148
+
1149
+ getItems: () => state.items,
1150
+
1151
+ setPosition: (position) => {
1152
+ state.position = position;
1153
+ if (state.visible) {
1154
+ const anchorElement = getAnchorElement();
1155
+ if (anchorElement) {
1156
+ positioner.positionMenu(anchorElement);
1157
+ }
1158
+ }
1159
+ return component;
1160
+ },
1161
+
1162
+ getPosition: () => state.position
1163
+ }
1164
+ };
1165
+ };
1166
+
1167
+ export default withController;