mtrl 0.3.5 → 0.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 (32) hide show
  1. package/package.json +1 -1
  2. package/src/components/menu/api.ts +143 -268
  3. package/src/components/menu/config.ts +84 -40
  4. package/src/components/menu/features/anchor.ts +159 -0
  5. package/src/components/menu/features/controller.ts +970 -0
  6. package/src/components/menu/features/index.ts +4 -0
  7. package/src/components/menu/index.ts +31 -63
  8. package/src/components/menu/menu.ts +107 -97
  9. package/src/components/menu/types.ts +263 -447
  10. package/src/core/dom/classes.ts +81 -9
  11. package/src/core/dom/create.ts +30 -19
  12. package/src/core/layout/README.md +531 -166
  13. package/src/core/layout/array.ts +3 -4
  14. package/src/core/layout/config.ts +193 -0
  15. package/src/core/layout/create.ts +1 -2
  16. package/src/core/layout/index.ts +12 -2
  17. package/src/core/layout/object.ts +2 -3
  18. package/src/core/layout/processor.ts +60 -12
  19. package/src/core/layout/result.ts +1 -2
  20. package/src/core/layout/types.ts +105 -50
  21. package/src/core/layout/utils.ts +69 -61
  22. package/src/index.ts +2 -1
  23. package/src/styles/components/_menu.scss +20 -8
  24. package/src/styles/main.scss +23 -23
  25. package/src/styles/utilities/_layout.scss +665 -0
  26. package/src/components/menu/features/items-manager.ts +0 -457
  27. package/src/components/menu/features/keyboard-navigation.ts +0 -133
  28. package/src/components/menu/features/positioning.ts +0 -127
  29. package/src/components/menu/features/visibility.ts +0 -230
  30. package/src/components/menu/menu-item.ts +0 -86
  31. package/src/components/menu/utils.ts +0 -67
  32. /package/src/{core/build → styles/utilities}/_ripple.scss +0 -0
@@ -0,0 +1,970 @@
1
+ // src/components/menu/features/controller.ts
2
+
3
+ import { MenuConfig, MenuContent, MenuItem, MenuDivider, MenuEvent, MenuSelectEvent } from '../types';
4
+
5
+ /**
6
+ * Adds controller functionality to the menu component
7
+ * Manages state, rendering, positioning, and event handling
8
+ *
9
+ * @param config - Menu configuration
10
+ * @returns Component enhancer with menu controller functionality
11
+ */
12
+ export const withController = (config: MenuConfig) => component => {
13
+ if (!component.element) {
14
+ console.warn('Cannot initialize menu controller: missing element');
15
+ return component;
16
+ }
17
+
18
+ // Initialize state
19
+ const state = {
20
+ visible: config.visible || false,
21
+ items: config.items || [],
22
+ placement: config.placement,
23
+ activeSubmenu: null,
24
+ activeSubmenuItem: null,
25
+ activeItemIndex: -1,
26
+ submenuTimer: null,
27
+ hoverIntent: {
28
+ timer: null,
29
+ activeItem: null
30
+ },
31
+ component
32
+ };
33
+
34
+ // Create event helpers
35
+ const eventHelpers = {
36
+ triggerEvent(eventName: string, data: any = {}, originalEvent?: Event) {
37
+ const eventData = {
38
+ menu: state.component,
39
+ ...data,
40
+ originalEvent,
41
+ preventDefault: () => { eventData.defaultPrevented = true; },
42
+ defaultPrevented: false
43
+ };
44
+
45
+ component.emit(eventName, eventData);
46
+ return eventData;
47
+ }
48
+ };
49
+
50
+ /**
51
+ * Gets the anchor element from config
52
+ */
53
+ const getAnchorElement = (): HTMLElement => {
54
+ const { anchor } = config;
55
+
56
+ if (typeof anchor === 'string') {
57
+ const element = document.querySelector(anchor);
58
+ if (!element) {
59
+ console.warn(`Menu anchor not found: ${anchor}`);
60
+ return null;
61
+ }
62
+ return element as HTMLElement;
63
+ }
64
+
65
+ return anchor;
66
+ };
67
+
68
+ /**
69
+ * Creates a DOM element for a menu item
70
+ */
71
+ const createMenuItem = (item: MenuItem, index: number): HTMLElement => {
72
+ const itemElement = document.createElement('li');
73
+ const itemClass = `${component.getClass('menu-item')}`;
74
+
75
+ itemElement.className = itemClass;
76
+ itemElement.setAttribute('role', 'menuitem');
77
+ itemElement.setAttribute('tabindex', '-1');
78
+ itemElement.setAttribute('data-id', item.id);
79
+ itemElement.setAttribute('data-index', index.toString());
80
+
81
+ if (item.disabled) {
82
+ itemElement.classList.add(`${itemClass}--disabled`);
83
+ itemElement.setAttribute('aria-disabled', 'true');
84
+ } else {
85
+ itemElement.setAttribute('aria-disabled', 'false');
86
+ }
87
+
88
+ if (item.hasSubmenu) {
89
+ itemElement.classList.add(`${itemClass}--submenu`);
90
+ itemElement.setAttribute('aria-haspopup', 'true');
91
+ itemElement.setAttribute('aria-expanded', 'false');
92
+ }
93
+
94
+ // Create content container for flexible layout
95
+ const contentContainer = document.createElement('span');
96
+ contentContainer.className = `${component.getClass('menu-item-content')}`;
97
+
98
+ // Add icon if provided
99
+ if (item.icon) {
100
+ const iconElement = document.createElement('span');
101
+ iconElement.className = `${component.getClass('menu-item-icon')}`;
102
+ iconElement.innerHTML = item.icon;
103
+ contentContainer.appendChild(iconElement);
104
+ }
105
+
106
+ // Add text
107
+ const textElement = document.createElement('span');
108
+ textElement.className = `${component.getClass('menu-item-text')}`;
109
+ textElement.textContent = item.text;
110
+ contentContainer.appendChild(textElement);
111
+
112
+ // Add shortcut if provided
113
+ if (item.shortcut) {
114
+ const shortcutElement = document.createElement('span');
115
+ shortcutElement.className = `${component.getClass('menu-item-shortcut')}`;
116
+ shortcutElement.textContent = item.shortcut;
117
+ contentContainer.appendChild(shortcutElement);
118
+ }
119
+
120
+ itemElement.appendChild(contentContainer);
121
+
122
+ // Add event listeners
123
+ if (!item.disabled) {
124
+ itemElement.addEventListener('click', (e) => handleItemClick(e, item, index));
125
+
126
+ if (item.hasSubmenu && config.openSubmenuOnHover) {
127
+ itemElement.addEventListener('mouseenter', () => handleSubmenuHover(item, index, itemElement));
128
+ itemElement.addEventListener('mouseleave', handleSubmenuLeave);
129
+ }
130
+ }
131
+
132
+ return itemElement;
133
+ };
134
+
135
+ /**
136
+ * Creates a DOM element for a menu divider
137
+ */
138
+ const createDivider = (divider: MenuDivider, index: number): HTMLElement => {
139
+ const dividerElement = document.createElement('li');
140
+ dividerElement.className = `${component.getClass('menu-divider')}`;
141
+ dividerElement.setAttribute('role', 'separator');
142
+ dividerElement.setAttribute('data-index', index.toString());
143
+
144
+ if (divider.id) {
145
+ dividerElement.setAttribute('id', divider.id);
146
+ }
147
+
148
+ return dividerElement;
149
+ };
150
+
151
+ /**
152
+ * Renders the menu items
153
+ */
154
+ const renderMenuItems = (): void => {
155
+ const menuList = document.createElement('ul');
156
+ menuList.className = `${component.getClass('menu-list')}`;
157
+ menuList.setAttribute('role', 'menu');
158
+
159
+ // Create items
160
+ state.items.forEach((item, index) => {
161
+ if ('type' in item && item.type === 'divider') {
162
+ menuList.appendChild(createDivider(item, index));
163
+ } else {
164
+ menuList.appendChild(createMenuItem(item as MenuItem, index));
165
+ }
166
+ });
167
+
168
+ // Clear and append
169
+ component.element.innerHTML = '';
170
+ component.element.appendChild(menuList);
171
+ };
172
+
173
+ /**
174
+ * Positions the menu relative to its anchor
175
+ */
176
+ const positionMenu = (): void => {
177
+ const anchor = getAnchorElement();
178
+ if (!anchor) return;
179
+
180
+ const menuElement = component.element;
181
+ const anchorRect = anchor.getBoundingClientRect();
182
+ const { placement } = state;
183
+ const offset = config.offset || 8;
184
+
185
+ // Reset styles for measurement
186
+ menuElement.style.top = '0';
187
+ menuElement.style.left = '0';
188
+ menuElement.style.right = 'auto';
189
+ menuElement.style.bottom = 'auto';
190
+ menuElement.style.maxHeight = config.maxHeight || '';
191
+
192
+ // Take measurements
193
+ const menuRect = menuElement.getBoundingClientRect();
194
+ const viewportWidth = window.innerWidth;
195
+ const viewportHeight = window.innerHeight;
196
+
197
+ // Calculate position based on placement
198
+ let top = 0;
199
+ let left = 0;
200
+
201
+ switch (placement) {
202
+ case 'top-start':
203
+ top = anchorRect.top - menuRect.height - offset;
204
+ left = anchorRect.left;
205
+ break;
206
+ case 'top':
207
+ top = anchorRect.top - menuRect.height - offset;
208
+ left = anchorRect.left + (anchorRect.width / 2) - (menuRect.width / 2);
209
+ break;
210
+ case 'top-end':
211
+ top = anchorRect.top - menuRect.height - offset;
212
+ left = anchorRect.right - menuRect.width;
213
+ break;
214
+ case 'right-start':
215
+ top = anchorRect.top;
216
+ left = anchorRect.right + offset;
217
+ break;
218
+ case 'right':
219
+ top = anchorRect.top + (anchorRect.height / 2) - (menuRect.height / 2);
220
+ left = anchorRect.right + offset;
221
+ break;
222
+ case 'right-end':
223
+ top = anchorRect.bottom - menuRect.height;
224
+ left = anchorRect.right + offset;
225
+ break;
226
+ case 'bottom-start':
227
+ top = anchorRect.bottom + offset;
228
+ left = anchorRect.left;
229
+ break;
230
+ case 'bottom':
231
+ top = anchorRect.bottom + offset;
232
+ left = anchorRect.left + (anchorRect.width / 2) - (menuRect.width / 2);
233
+ break;
234
+ case 'bottom-end':
235
+ top = anchorRect.bottom + offset;
236
+ left = anchorRect.right - menuRect.width;
237
+ break;
238
+ case 'left-start':
239
+ top = anchorRect.top;
240
+ left = anchorRect.left - menuRect.width - offset;
241
+ break;
242
+ case 'left':
243
+ top = anchorRect.top + (anchorRect.height / 2) - (menuRect.height / 2);
244
+ left = anchorRect.left - menuRect.width - offset;
245
+ break;
246
+ case 'left-end':
247
+ top = anchorRect.bottom - menuRect.height;
248
+ left = anchorRect.left - menuRect.width - offset;
249
+ break;
250
+ }
251
+
252
+ // Auto-flip if needed to stay in viewport
253
+ if (config.autoFlip) {
254
+ // Flip vertically if needed
255
+ if (top < 0) {
256
+ if (placement.startsWith('top')) {
257
+ top = anchorRect.bottom + offset;
258
+ }
259
+ } else if (top + menuRect.height > viewportHeight) {
260
+ if (placement.startsWith('bottom')) {
261
+ top = anchorRect.top - menuRect.height - offset;
262
+ }
263
+ }
264
+
265
+ // Flip horizontally if needed
266
+ if (left < 0) {
267
+ if (placement.startsWith('left')) {
268
+ left = anchorRect.right + offset;
269
+ } else if (placement.includes('start')) {
270
+ left = 0;
271
+ }
272
+ } else if (left + menuRect.width > viewportWidth) {
273
+ if (placement.startsWith('right')) {
274
+ left = anchorRect.left - menuRect.width - offset;
275
+ } else if (placement.includes('end')) {
276
+ left = viewportWidth - menuRect.width;
277
+ }
278
+ }
279
+ }
280
+
281
+ // Apply position
282
+ menuElement.style.top = `${top}px`;
283
+ menuElement.style.left = `${left}px`;
284
+
285
+ // Ensure it's in viewport
286
+ const updatedRect = menuElement.getBoundingClientRect();
287
+ if (updatedRect.top < 0) {
288
+ menuElement.style.top = '0';
289
+ }
290
+ if (updatedRect.left < 0) {
291
+ menuElement.style.left = '0';
292
+ }
293
+ };
294
+
295
+ /**
296
+ * Clean up hover intent timer
297
+ */
298
+ const clearHoverIntent = () => {
299
+ if (state.hoverIntent.timer) {
300
+ clearTimeout(state.hoverIntent.timer);
301
+ state.hoverIntent.timer = null;
302
+ state.hoverIntent.activeItem = null;
303
+ }
304
+ };
305
+
306
+ /**
307
+ * Handles click on a menu item
308
+ */
309
+ const handleItemClick = (e: MouseEvent, item: MenuItem, index: number): void => {
310
+ e.preventDefault();
311
+ e.stopPropagation();
312
+
313
+ // Don't process if disabled
314
+ if (item.disabled) return;
315
+
316
+ if (item.hasSubmenu) {
317
+ handleSubmenuClick(item, index, e.currentTarget as HTMLElement);
318
+ return;
319
+ }
320
+
321
+ // Trigger select event
322
+ const selectEvent = eventHelpers.triggerEvent('select', {
323
+ item,
324
+ itemId: item.id,
325
+ itemData: item.data
326
+ }, e) as MenuSelectEvent;
327
+
328
+ // Close menu if needed
329
+ if (config.closeOnSelect && !selectEvent.defaultPrevented) {
330
+ closeMenu(e);
331
+ }
332
+ };
333
+
334
+ /**
335
+ * Handles click on a submenu item
336
+ */
337
+ const handleSubmenuClick = (item: MenuItem, index: number, itemElement: HTMLElement, viaKeyboard = false): void => {
338
+ if (!item.submenu || !item.hasSubmenu) return;
339
+
340
+ const isOpen = itemElement.getAttribute('aria-expanded') === 'true';
341
+
342
+ if (isOpen) {
343
+ // Close submenu
344
+ closeSubmenu();
345
+ } else {
346
+ // Close any open submenu
347
+ closeSubmenu();
348
+
349
+ // Open new submenu
350
+ openSubmenu(item, index, itemElement, viaKeyboard);
351
+ }
352
+ };
353
+
354
+ /**
355
+ * Handles hover on a submenu item
356
+ */
357
+ const handleSubmenuHover = (item: MenuItem, index: number, itemElement: HTMLElement): void => {
358
+ if (!config.openSubmenuOnHover || !item.hasSubmenu) return;
359
+
360
+ // Clear any existing timers
361
+ clearHoverIntent();
362
+ clearSubmenuTimer();
363
+
364
+ // Set hover intent
365
+ state.hoverIntent.activeItem = itemElement;
366
+ state.hoverIntent.timer = setTimeout(() => {
367
+ const isCurrentlyHovered = itemElement.matches(':hover');
368
+ if (isCurrentlyHovered) {
369
+ // Only close and reopen if this is a different submenu item
370
+ if (state.activeSubmenuItem !== itemElement) {
371
+ closeSubmenu();
372
+ openSubmenu(item, index, itemElement);
373
+ }
374
+ }
375
+ state.hoverIntent.timer = null;
376
+ }, 100);
377
+ };
378
+
379
+ /**
380
+ * Handles mouse leave from submenu
381
+ */
382
+ const handleSubmenuLeave = (e: MouseEvent): void => {
383
+ // Clear hover intent
384
+ clearHoverIntent();
385
+
386
+ // Don't close immediately to allow moving to submenu
387
+ clearSubmenuTimer();
388
+
389
+ // Set a timer to close the submenu if not re-entered
390
+ state.submenuTimer = setTimeout(() => {
391
+ // Check if mouse is over the submenu or the parent menu item
392
+ const submenuElement = state.activeSubmenu;
393
+ const menuItemElement = state.activeSubmenuItem;
394
+
395
+ if (submenuElement && menuItemElement) {
396
+ const overSubmenu = submenuElement.matches(':hover');
397
+ const overMenuItem = menuItemElement.matches(':hover');
398
+
399
+ if (!overSubmenu && !overMenuItem) {
400
+ closeSubmenu();
401
+ }
402
+ }
403
+
404
+ state.submenuTimer = null;
405
+ }, 300);
406
+ };
407
+
408
+ /**
409
+ * Opens a submenu
410
+ */
411
+ const openSubmenu = (item: MenuItem, index: number, itemElement: HTMLElement, viaKeyboard = false): void => {
412
+ if (!item.submenu || !item.hasSubmenu) return;
413
+
414
+ // Close any existing submenu
415
+ closeSubmenu();
416
+
417
+ // Set expanded state
418
+ itemElement.setAttribute('aria-expanded', 'true');
419
+
420
+ // Create submenu element
421
+ const submenuElement = document.createElement('div');
422
+ submenuElement.className = `${component.getClass('menu')} ${component.getClass('menu--submenu')}`;
423
+ submenuElement.setAttribute('role', 'menu');
424
+ submenuElement.setAttribute('tabindex', '-1');
425
+
426
+ // Create submenu list
427
+ const submenuList = document.createElement('ul');
428
+ submenuList.className = `${component.getClass('menu-list')}`;
429
+
430
+ // Create submenu items
431
+ const submenuItems = [];
432
+ item.submenu.forEach((subitem, subindex) => {
433
+ if ('type' in subitem && subitem.type === 'divider') {
434
+ submenuList.appendChild(createDivider(subitem, subindex));
435
+ } else {
436
+ const subitemElement = createMenuItem(subitem as MenuItem, subindex);
437
+ submenuList.appendChild(subitemElement);
438
+ if (!(subitem as MenuItem).disabled) {
439
+ submenuItems.push(subitemElement);
440
+ }
441
+ }
442
+ });
443
+
444
+ submenuElement.appendChild(submenuList);
445
+ document.body.appendChild(submenuElement);
446
+
447
+ // Setup keyboard navigation for submenu
448
+ submenuElement.addEventListener('keydown', handleMenuKeydown);
449
+
450
+ // Add mouseenter event to prevent closing
451
+ submenuElement.addEventListener('mouseenter', () => {
452
+ clearSubmenuTimer();
453
+ });
454
+
455
+ // Add mouseleave event to handle closing
456
+ submenuElement.addEventListener('mouseleave', (e) => {
457
+ handleSubmenuLeave(e);
458
+ });
459
+
460
+ // Position the submenu next to its parent item
461
+ const itemRect = itemElement.getBoundingClientRect();
462
+
463
+ // Default position is to the right
464
+ submenuElement.style.top = `${itemRect.top}px`;
465
+ submenuElement.style.left = `${itemRect.right + 8}px`;
466
+
467
+ // Check if submenu would be outside viewport and adjust if needed
468
+ const submenuRect = submenuElement.getBoundingClientRect();
469
+ const viewportWidth = window.innerWidth;
470
+
471
+ if (submenuRect.right > viewportWidth) {
472
+ // Position to the left instead
473
+ submenuElement.style.left = 'auto';
474
+ submenuElement.style.right = `${window.innerWidth - itemRect.left + 8}px`;
475
+ }
476
+
477
+ // Add to state
478
+ state.activeSubmenu = submenuElement;
479
+ state.activeSubmenuItem = itemElement;
480
+
481
+ // Add submenu events
482
+ document.addEventListener('click', handleDocumentClickForSubmenu);
483
+ window.addEventListener('resize', handleWindowResizeForSubmenu);
484
+ window.addEventListener('scroll', handleWindowScrollForSubmenu);
485
+
486
+ // Make visible
487
+ setTimeout(() => {
488
+ submenuElement.classList.add(`${component.getClass('menu--visible')}`);
489
+
490
+ // If opened via keyboard, focus the first item in the submenu
491
+ if (viaKeyboard && submenuItems.length > 0) {
492
+ setTimeout(() => {
493
+ submenuItems[0].focus();
494
+ }, 50);
495
+ }
496
+ }, 10);
497
+ };
498
+
499
+ /**
500
+ * Clear submenu close timer
501
+ */
502
+ const clearSubmenuTimer = () => {
503
+ if (state.submenuTimer) {
504
+ clearTimeout(state.submenuTimer);
505
+ state.submenuTimer = null;
506
+ }
507
+ };
508
+
509
+ /**
510
+ * Closes any open submenu
511
+ */
512
+ const closeSubmenu = (): void => {
513
+ // Clear timers
514
+ clearHoverIntent();
515
+ clearSubmenuTimer();
516
+
517
+ if (!state.activeSubmenu) return;
518
+
519
+ // Remove expanded state from all items
520
+ if (state.activeSubmenuItem) {
521
+ state.activeSubmenuItem.setAttribute('aria-expanded', 'false');
522
+ }
523
+
524
+ // Remove submenu element with animation
525
+ state.activeSubmenu.classList.remove(`${component.getClass('menu--visible')}`);
526
+
527
+ // Store reference for cleanup
528
+ const submenuToRemove = state.activeSubmenu;
529
+
530
+ // Remove after animation
531
+ setTimeout(() => {
532
+ if (submenuToRemove && submenuToRemove.parentNode) {
533
+ submenuToRemove.parentNode.removeChild(submenuToRemove);
534
+ }
535
+ }, 200);
536
+
537
+ // Clear state
538
+ state.activeSubmenu = null;
539
+ state.activeSubmenuItem = null;
540
+
541
+ // Remove document events
542
+ document.removeEventListener('click', handleDocumentClickForSubmenu);
543
+ window.removeEventListener('resize', handleWindowResizeForSubmenu);
544
+ window.removeEventListener('scroll', handleWindowScrollForSubmenu);
545
+ };
546
+
547
+ /**
548
+ * Handles document click for submenu
549
+ */
550
+ const handleDocumentClickForSubmenu = (e: MouseEvent): void => {
551
+ if (!state.activeSubmenu) return;
552
+
553
+ const submenuElement = state.activeSubmenu;
554
+ const menuItemElement = state.activeSubmenuItem;
555
+
556
+ // Check if click was inside submenu or parent menu item
557
+ if (submenuElement.contains(e.target as Node) ||
558
+ (menuItemElement && menuItemElement.contains(e.target as Node))) {
559
+ return;
560
+ }
561
+
562
+ // Close submenu if clicked outside
563
+ closeSubmenu();
564
+ };
565
+
566
+ /**
567
+ * Handles window resize for submenu
568
+ */
569
+ const handleWindowResizeForSubmenu = (): void => {
570
+ // Reposition or close submenu on resize
571
+ closeSubmenu();
572
+ };
573
+
574
+ /**
575
+ * Handles window scroll for submenu
576
+ */
577
+ const handleWindowScrollForSubmenu = (): void => {
578
+ // Reposition or close submenu on scroll
579
+ closeSubmenu();
580
+ };
581
+
582
+ /**
583
+ * Opens the menu
584
+ */
585
+ /**
586
+ * Opens the menu
587
+ */
588
+ const openMenu = (event?: Event): void => {
589
+ if (state.visible) return;
590
+
591
+ // Update state
592
+ state.visible = true;
593
+
594
+ // Add the menu to the DOM if it's not already there
595
+ if (!component.element.parentNode) {
596
+ document.body.appendChild(component.element);
597
+ }
598
+
599
+ // Set attributes
600
+ component.element.setAttribute('aria-hidden', 'false');
601
+ component.element.classList.add(`${component.getClass('menu--visible')}`);
602
+
603
+ // Position
604
+ positionMenu();
605
+
606
+ // Focus first item
607
+ setTimeout(() => {
608
+ focusFirstItem();
609
+ }, 100);
610
+
611
+ // Add document events
612
+ if (config.closeOnClickOutside) {
613
+ document.addEventListener('click', handleDocumentClick);
614
+ }
615
+ if (config.closeOnEscape) {
616
+ document.addEventListener('keydown', handleDocumentKeydown);
617
+ }
618
+ window.addEventListener('resize', handleWindowResize);
619
+ window.addEventListener('scroll', handleWindowScroll);
620
+
621
+ // Trigger event
622
+ eventHelpers.triggerEvent('open', {}, event);
623
+ };
624
+
625
+ /**
626
+ * Closes the menu
627
+ */
628
+ const closeMenu = (event?: Event): void => {
629
+ if (!state.visible) return;
630
+
631
+ // Close any open submenu first
632
+ closeSubmenu();
633
+
634
+ // Update state
635
+ state.visible = false;
636
+
637
+ // Set attributes
638
+ component.element.setAttribute('aria-hidden', 'true');
639
+ component.element.classList.remove(`${component.getClass('menu--visible')}`);
640
+
641
+ // Remove document events
642
+ document.removeEventListener('click', handleDocumentClick);
643
+ document.removeEventListener('keydown', handleDocumentKeydown);
644
+ window.removeEventListener('resize', handleWindowResize);
645
+ window.removeEventListener('scroll', handleWindowScroll);
646
+
647
+ // Trigger event
648
+ eventHelpers.triggerEvent('close', {}, event);
649
+
650
+ // Remove from DOM after animation completes
651
+ setTimeout(() => {
652
+ if (component.element.parentNode && !state.visible) {
653
+ component.element.parentNode.removeChild(component.element);
654
+ }
655
+ }, 300); // Match the animation duration in CSS
656
+ };
657
+
658
+ /**
659
+ * Toggles the menu
660
+ */
661
+ const toggleMenu = (event?: Event): void => {
662
+ if (state.visible) {
663
+ closeMenu(event);
664
+ } else {
665
+ openMenu(event);
666
+ }
667
+ };
668
+
669
+ /**
670
+ * Handles document click
671
+ */
672
+ const handleDocumentClick = (e: MouseEvent): void => {
673
+ // Don't close if clicked inside menu
674
+ if (component.element.contains(e.target as Node)) {
675
+ return;
676
+ }
677
+
678
+ // Check if clicked on anchor element
679
+ const anchor = getAnchorElement();
680
+ if (anchor && anchor.contains(e.target as Node)) {
681
+ return;
682
+ }
683
+
684
+ // Close menu
685
+ closeMenu(e);
686
+ };
687
+
688
+ /**
689
+ * Handles document keydown
690
+ */
691
+ const handleDocumentKeydown = (e: KeyboardEvent): void => {
692
+ if (e.key === 'Escape') {
693
+ closeMenu(e);
694
+ }
695
+ };
696
+
697
+ /**
698
+ * Handles window resize
699
+ */
700
+ const handleWindowResize = (): void => {
701
+ if (state.visible) {
702
+ positionMenu();
703
+ }
704
+ };
705
+
706
+ /**
707
+ * Handles window scroll
708
+ */
709
+ const handleWindowScroll = (): void => {
710
+ if (state.visible) {
711
+ positionMenu();
712
+ }
713
+ };
714
+
715
+ /**
716
+ * Sets focus to the menu itself, but doesn't auto-select the first item
717
+ * This improves usability by not having an item automatically selected
718
+ */
719
+ const focusFirstItem = (): void => {
720
+ // Instead of focusing the first item directly, focus the menu container
721
+ // which will still allow keyboard navigation to work
722
+ component.element.setAttribute('tabindex', '0');
723
+ component.element.focus();
724
+
725
+ // Reset active item index
726
+ state.activeItemIndex = -1;
727
+ };
728
+
729
+ /**
730
+ * Handles keydown events on the menu or submenu
731
+ */
732
+ const handleMenuKeydown = (e: KeyboardEvent): void => {
733
+ // Determine if this event is from the main menu or a submenu
734
+ const isSubmenu = state.activeSubmenu && state.activeSubmenu.contains(e.target as Node);
735
+
736
+ // Get the appropriate menu element
737
+ const menuElement = isSubmenu ? state.activeSubmenu : component.element;
738
+
739
+ // Get all non-disabled menu items from the current menu
740
+ const items = Array.from(menuElement.querySelectorAll(
741
+ `.${component.getClass('menu-item')}:not(.${component.getClass('menu-item--disabled')})`
742
+ )) as HTMLElement[];
743
+
744
+ if (items.length === 0) return;
745
+
746
+ // Get the currently focused item index
747
+ let focusedItemIndex = -1;
748
+ const focusedElement = menuElement.querySelector(':focus') as HTMLElement;
749
+ if (focusedElement && focusedElement.classList.contains(component.getClass('menu-item'))) {
750
+ focusedItemIndex = items.indexOf(focusedElement);
751
+ }
752
+
753
+ switch (e.key) {
754
+ case 'ArrowDown':
755
+ case 'Down':
756
+ e.preventDefault();
757
+ // If no item is active, select the first one
758
+ if (focusedItemIndex < 0) {
759
+ items[0].focus();
760
+ } else if (focusedItemIndex < items.length - 1) {
761
+ items[focusedItemIndex + 1].focus();
762
+ } else {
763
+ items[0].focus();
764
+ }
765
+ break;
766
+
767
+ case 'ArrowUp':
768
+ case 'Up':
769
+ e.preventDefault();
770
+ // If no item is active, select the last one
771
+ if (focusedItemIndex < 0) {
772
+ items[items.length - 1].focus();
773
+ } else if (focusedItemIndex > 0) {
774
+ items[focusedItemIndex - 1].focus();
775
+ } else {
776
+ items[items.length - 1].focus();
777
+ }
778
+ break;
779
+
780
+ case 'Home':
781
+ e.preventDefault();
782
+ items[0].focus();
783
+ break;
784
+
785
+ case 'End':
786
+ e.preventDefault();
787
+ items[items.length - 1].focus();
788
+ break;
789
+
790
+ case 'Enter':
791
+ case ' ':
792
+ e.preventDefault();
793
+ // If an item is focused, click it
794
+ if (focusedItemIndex >= 0) {
795
+ items[focusedItemIndex].click();
796
+ }
797
+ break;
798
+
799
+ case 'ArrowRight':
800
+ case 'Right':
801
+ e.preventDefault();
802
+ // Handle right arrow in different contexts
803
+ if (isSubmenu) {
804
+ // In a submenu, right arrow opens nested submenus
805
+ if (focusedItemIndex >= 0 && items[focusedItemIndex].classList.contains(`${component.getClass('menu-item--submenu')}`)) {
806
+ items[focusedItemIndex].click();
807
+ }
808
+ } else {
809
+ // In main menu, right arrow opens a submenu
810
+ if (focusedItemIndex >= 0 && items[focusedItemIndex].classList.contains(`${component.getClass('menu-item--submenu')}`)) {
811
+ // Get the correct menu item data
812
+ const itemElement = items[focusedItemIndex];
813
+ const itemIndex = parseInt(itemElement.getAttribute('data-index'), 10);
814
+ const itemData = state.items[itemIndex] as MenuItem;
815
+
816
+ // Open submenu via keyboard
817
+ handleSubmenuClick(itemData, itemIndex, itemElement, true);
818
+ }
819
+ }
820
+ break;
821
+
822
+ case 'ArrowLeft':
823
+ case 'Left':
824
+ e.preventDefault();
825
+ // Handle left arrow in different contexts
826
+ if (isSubmenu) {
827
+ // In a submenu, left arrow returns to the parent menu
828
+ if (state.activeSubmenuItem) {
829
+ // Store the reference to the parent item before closing the submenu
830
+ const parentItem = state.activeSubmenuItem;
831
+ closeSubmenu();
832
+ // Focus the parent item after closing
833
+ parentItem.focus();
834
+ } else {
835
+ closeSubmenu();
836
+ }
837
+ }
838
+ break;
839
+
840
+ case 'Escape':
841
+ e.preventDefault();
842
+ if (isSubmenu) {
843
+ // In a submenu, Escape closes just the submenu
844
+ if (state.activeSubmenuItem) {
845
+ // Store the reference to the parent item before closing the submenu
846
+ const parentItem = state.activeSubmenuItem;
847
+ closeSubmenu();
848
+ // Focus the parent item after closing
849
+ parentItem.focus();
850
+ } else {
851
+ closeSubmenu();
852
+ }
853
+ } else {
854
+ // In main menu, Escape closes the entire menu
855
+ closeMenu(e);
856
+ }
857
+ break;
858
+
859
+ case 'Tab':
860
+ // Close the menu when tabbing out
861
+ if (!isSubmenu) {
862
+ closeMenu();
863
+ }
864
+ break;
865
+ }
866
+ };
867
+
868
+ /**
869
+ * Sets up the menu
870
+ */
871
+ const initMenu = () => {
872
+ // Set up menu structure
873
+ renderMenuItems();
874
+
875
+ // Set up keyboard navigation
876
+ component.element.addEventListener('keydown', handleMenuKeydown);
877
+
878
+ // Position if visible
879
+ if (state.visible) {
880
+ positionMenu();
881
+
882
+ // Show immediately
883
+ component.element.classList.add(`${component.getClass('menu--visible')}`);
884
+
885
+ // Set up document events
886
+ if (config.closeOnClickOutside) {
887
+ document.addEventListener('click', handleDocumentClick);
888
+ }
889
+ if (config.closeOnEscape) {
890
+ document.addEventListener('keydown', handleDocumentKeydown);
891
+ }
892
+ window.addEventListener('resize', handleWindowResize);
893
+ window.addEventListener('scroll', handleWindowScroll);
894
+ }
895
+ };
896
+
897
+ // Initialize after DOM is ready
898
+ setTimeout(initMenu, 0);
899
+
900
+ // Register with lifecycle if available
901
+ if (component.lifecycle) {
902
+ const originalDestroy = component.lifecycle.destroy || (() => {});
903
+ component.lifecycle.destroy = () => {
904
+ // Clean up timers
905
+ clearHoverIntent();
906
+ clearSubmenuTimer();
907
+
908
+ // Clean up document events
909
+ document.removeEventListener('click', handleDocumentClick);
910
+ document.removeEventListener('keydown', handleDocumentKeydown);
911
+ window.removeEventListener('resize', handleWindowResize);
912
+ window.removeEventListener('scroll', handleWindowScroll);
913
+
914
+ // Clean up submenu events
915
+ document.removeEventListener('click', handleDocumentClickForSubmenu);
916
+ window.removeEventListener('resize', handleWindowResizeForSubmenu);
917
+ window.removeEventListener('scroll', handleWindowScrollForSubmenu);
918
+
919
+ // Clean up submenu element
920
+ if (state.activeSubmenu && state.activeSubmenu.parentNode) {
921
+ state.activeSubmenu.parentNode.removeChild(state.activeSubmenu);
922
+ }
923
+
924
+ originalDestroy();
925
+ };
926
+ }
927
+
928
+ // Return enhanced component
929
+ return {
930
+ ...component,
931
+ menu: {
932
+ open: (event) => {
933
+ openMenu(event);
934
+ return component;
935
+ },
936
+
937
+ close: (event) => {
938
+ closeMenu(event);
939
+ return component;
940
+ },
941
+
942
+ toggle: (event) => {
943
+ toggleMenu(event);
944
+ return component;
945
+ },
946
+
947
+ isOpen: () => state.visible,
948
+
949
+ setItems: (items) => {
950
+ state.items = items;
951
+ renderMenuItems();
952
+ return component;
953
+ },
954
+
955
+ getItems: () => state.items,
956
+
957
+ setPlacement: (placement) => {
958
+ state.placement = placement;
959
+ if (state.visible) {
960
+ positionMenu();
961
+ }
962
+ return component;
963
+ },
964
+
965
+ getPlacement: () => state.placement
966
+ }
967
+ };
968
+ };
969
+
970
+ export default withController;