mtrl 0.3.6 → 0.3.8

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 (45) hide show
  1. package/package.json +2 -2
  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 +61 -22
  5. package/src/components/menu/config.ts +10 -8
  6. package/src/components/menu/features/anchor.ts +254 -19
  7. package/src/components/menu/features/controller.ts +724 -271
  8. package/src/components/menu/features/index.ts +11 -2
  9. package/src/components/menu/features/position.ts +353 -0
  10. package/src/components/menu/index.ts +5 -5
  11. package/src/components/menu/menu.ts +21 -61
  12. package/src/components/menu/types.ts +30 -16
  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 +331 -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/index.ts +4 -45
  34. package/src/styles/abstract/_variables.scss +18 -0
  35. package/src/styles/components/_button.scss +21 -5
  36. package/src/styles/components/{_chip.scss → _chips.scss} +118 -4
  37. package/src/styles/components/_menu.scss +97 -24
  38. package/src/styles/components/_select.scss +272 -0
  39. package/src/styles/components/_textfield.scss +233 -42
  40. package/src/styles/main.scss +2 -1
  41. package/src/components/textfield/features.ts +0 -322
  42. package/src/core/collection/adapters/base.js +0 -26
  43. package/src/core/collection/collection.js +0 -259
  44. package/src/core/collection/list-manager.js +0 -157
  45. /package/src/core/collection/adapters/{route.js → route.ts} +0 -0
@@ -1,6 +1,9 @@
1
1
  // src/components/menu/features/controller.ts
2
2
 
3
3
  import { MenuConfig, MenuContent, MenuItem, MenuDivider, MenuEvent, MenuSelectEvent } from '../types';
4
+ import { createPositioner } from './position';
5
+
6
+ let ignoreNextDocumentClick = false;
4
7
 
5
8
  /**
6
9
  * Adds controller functionality to the menu component
@@ -9,7 +12,7 @@ import { MenuConfig, MenuContent, MenuItem, MenuDivider, MenuEvent, MenuSelectEv
9
12
  * @param config - Menu configuration
10
13
  * @returns Component enhancer with menu controller functionality
11
14
  */
12
- export const withController = (config: MenuConfig) => component => {
15
+ const withController = (config: MenuConfig) => component => {
13
16
  if (!component.element) {
14
17
  console.warn('Cannot initialize menu controller: missing element');
15
18
  return component;
@@ -19,18 +22,30 @@ export const withController = (config: MenuConfig) => component => {
19
22
  const state = {
20
23
  visible: config.visible || false,
21
24
  items: config.items || [],
22
- placement: config.placement,
23
- activeSubmenu: null,
24
- activeSubmenuItem: null,
25
+ position: config.position,
26
+ selectedItemId: null as string | null,
27
+ activeSubmenu: null as HTMLElement,
28
+ activeSubmenuItem: null as HTMLElement,
25
29
  activeItemIndex: -1,
30
+ submenuLevel: 0, // Track nesting level of submenus
31
+ activeSubmenus: [] as Array<{
32
+ element: HTMLElement,
33
+ menuItem: HTMLElement,
34
+ level: number,
35
+ isOpening: boolean // Track if submenu is in opening transition
36
+ }>,
26
37
  submenuTimer: null,
27
38
  hoverIntent: {
28
39
  timer: null,
29
40
  activeItem: null
30
41
  },
31
- component
42
+ component,
43
+ keyboardNavActive: false // Track if keyboard navigation is active
32
44
  };
33
45
 
46
+ // Create positioner
47
+ const positioner = createPositioner(component, config);
48
+
34
49
  // Create event helpers
35
50
  const eventHelpers = {
36
51
  triggerEvent(eventName: string, data: any = {}, originalEvent?: Event) {
@@ -51,6 +66,12 @@ export const withController = (config: MenuConfig) => component => {
51
66
  * Gets the anchor element from config
52
67
  */
53
68
  const getAnchorElement = (): HTMLElement => {
69
+ // First try to get the resolved anchor from the anchor feature
70
+ if (component.anchor && typeof component.anchor.getAnchor === 'function') {
71
+ return component.anchor.getAnchor();
72
+ }
73
+
74
+ // Fall back to config anchor for initial positioning
54
75
  const { anchor } = config;
55
76
 
56
77
  if (typeof anchor === 'string') {
@@ -62,7 +83,13 @@ export const withController = (config: MenuConfig) => component => {
62
83
  return element as HTMLElement;
63
84
  }
64
85
 
65
- return anchor;
86
+ // Handle component with element property
87
+ if (typeof anchor === 'object' && anchor !== null && 'element' in anchor) {
88
+ return anchor.element;
89
+ }
90
+
91
+ // Handle direct HTML element
92
+ return anchor as HTMLElement;
66
93
  };
67
94
 
68
95
  /**
@@ -74,7 +101,7 @@ export const withController = (config: MenuConfig) => component => {
74
101
 
75
102
  itemElement.className = itemClass;
76
103
  itemElement.setAttribute('role', 'menuitem');
77
- itemElement.setAttribute('tabindex', '-1');
104
+ itemElement.setAttribute('tabindex', '-1'); // Set to -1 by default, will update when needed
78
105
  itemElement.setAttribute('data-id', item.id);
79
106
  itemElement.setAttribute('data-index', index.toString());
80
107
 
@@ -85,6 +112,14 @@ export const withController = (config: MenuConfig) => component => {
85
112
  itemElement.setAttribute('aria-disabled', 'false');
86
113
  }
87
114
 
115
+ if (state.selectedItemId && item.id === state.selectedItemId) {
116
+ itemElement.classList.add(`${itemClass}--selected`);
117
+ itemElement.setAttribute('aria-selected', 'true');
118
+ } else {
119
+ itemElement.setAttribute('aria-selected', 'false');
120
+ }
121
+
122
+
88
123
  if (item.hasSubmenu) {
89
124
  itemElement.classList.add(`${itemClass}--submenu`);
90
125
  itemElement.setAttribute('aria-haspopup', 'true');
@@ -121,8 +156,23 @@ export const withController = (config: MenuConfig) => component => {
121
156
 
122
157
  // Add event listeners
123
158
  if (!item.disabled) {
159
+ // Mouse events
124
160
  itemElement.addEventListener('click', (e) => handleItemClick(e, item, index));
125
161
 
162
+ // Focus and blur events for proper focus styling
163
+ itemElement.addEventListener('focus', () => {
164
+ state.activeItemIndex = index;
165
+ state.keyboardNavActive = true;
166
+ });
167
+
168
+ // Additional keyboard event handler for accessibility
169
+ itemElement.addEventListener('keydown', (e) => {
170
+ if (e.key === 'Enter' || e.key === ' ') {
171
+ e.preventDefault();
172
+ handleItemClick(e, item, index);
173
+ }
174
+ });
175
+
126
176
  if (item.hasSubmenu && config.openSubmenuOnHover) {
127
177
  itemElement.addEventListener('mouseenter', () => handleSubmenuHover(item, index, itemElement));
128
178
  itemElement.addEventListener('mouseleave', handleSubmenuLeave);
@@ -170,128 +220,6 @@ export const withController = (config: MenuConfig) => component => {
170
220
  component.element.appendChild(menuList);
171
221
  };
172
222
 
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
223
  /**
296
224
  * Clean up hover intent timer
297
225
  */
@@ -303,6 +231,56 @@ export const withController = (config: MenuConfig) => component => {
303
231
  }
304
232
  };
305
233
 
234
+ /**
235
+ * Sets focus appropriately based on interaction type
236
+ * For keyboard interactions, focuses the first item
237
+ * For mouse interactions, makes the menu container focusable but doesn't auto-focus
238
+ *
239
+ * @param {'keyboard'|'mouse'} interactionType - Type of interaction that opened the menu
240
+ */
241
+ const handleFocus = (interactionType: 'keyboard' | 'mouse'): void => {
242
+ // Reset active item index
243
+ state.activeItemIndex = -1;
244
+
245
+ if (interactionType === 'keyboard') {
246
+ // Find all focusable items
247
+ const items = Array.from(
248
+ component.element.querySelectorAll(`.${component.getClass('menu-item')}:not(.${component.getClass('menu-item--disabled')})`)
249
+ ) as HTMLElement[];
250
+
251
+ if (items.length > 0) {
252
+ // Set all items to tabindex -1 except the first one
253
+ items.forEach((item, index) => {
254
+ item.setAttribute('tabindex', index === 0 ? '0' : '-1');
255
+ });
256
+
257
+ // Focus the first item for keyboard navigation
258
+ items[0].focus();
259
+ state.activeItemIndex = 0;
260
+ state.keyboardNavActive = true;
261
+ } else {
262
+ // If no items, focus the menu itself
263
+ component.element.setAttribute('tabindex', '0');
264
+ component.element.focus();
265
+ }
266
+ } else {
267
+ // For mouse interaction, make the menu focusable but don't auto-focus
268
+ component.element.setAttribute('tabindex', '-1');
269
+
270
+ // Still set up the tabindex correctly for potential keyboard navigation
271
+ const items = Array.from(
272
+ component.element.querySelectorAll(`.${component.getClass('menu-item')}:not(.${component.getClass('menu-item--disabled')})`)
273
+ ) as HTMLElement[];
274
+
275
+ if (items.length > 0) {
276
+ // Set all items to tabindex -1 except the first one
277
+ items.forEach((item, index) => {
278
+ item.setAttribute('tabindex', index === 0 ? '0' : '-1');
279
+ });
280
+ }
281
+ }
282
+ };
283
+
306
284
  /**
307
285
  * Handles click on a menu item
308
286
  */
@@ -337,15 +315,31 @@ export const withController = (config: MenuConfig) => component => {
337
315
  const handleSubmenuClick = (item: MenuItem, index: number, itemElement: HTMLElement, viaKeyboard = false): void => {
338
316
  if (!item.submenu || !item.hasSubmenu) return;
339
317
 
318
+ // Check if the submenu is already open
340
319
  const isOpen = itemElement.getAttribute('aria-expanded') === 'true';
341
320
 
321
+ // Find if any submenu is currently in opening transition
322
+ const anySubmenuTransitioning = state.activeSubmenus.some(s => s.isOpening);
323
+
324
+ // Completely ignore clicks during any submenu transition
325
+ if (anySubmenuTransitioning) {
326
+ return;
327
+ }
328
+
342
329
  if (isOpen) {
343
- // Close submenu
344
- closeSubmenu();
345
- } else {
346
- // Close any open submenu
347
- closeSubmenu();
330
+ // Close submenu - only if fully open
331
+ // Find the closest submenu level
332
+ const currentLevel = parseInt(
333
+ itemElement.closest(`.${component.getClass('menu--submenu')}`)?.getAttribute('data-level') || '0',
334
+ 10
335
+ );
348
336
 
337
+ // Close this level + 1 and deeper
338
+ closeSubmenuAtLevel(currentLevel + 1);
339
+
340
+ // Reset expanded state
341
+ itemElement.setAttribute('aria-expanded', 'false');
342
+ } else {
349
343
  // Open new submenu
350
344
  openSubmenu(item, index, itemElement, viaKeyboard);
351
345
  }
@@ -357,6 +351,9 @@ export const withController = (config: MenuConfig) => component => {
357
351
  const handleSubmenuHover = (item: MenuItem, index: number, itemElement: HTMLElement): void => {
358
352
  if (!config.openSubmenuOnHover || !item.hasSubmenu) return;
359
353
 
354
+ // If keyboard navigation is active, don't open submenu on hover
355
+ if (state.keyboardNavActive) return;
356
+
360
357
  // Clear any existing timers
361
358
  clearHoverIntent();
362
359
  clearSubmenuTimer();
@@ -368,7 +365,6 @@ export const withController = (config: MenuConfig) => component => {
368
365
  if (isCurrentlyHovered) {
369
366
  // Only close and reopen if this is a different submenu item
370
367
  if (state.activeSubmenuItem !== itemElement) {
371
- closeSubmenu();
372
368
  openSubmenu(item, index, itemElement);
373
369
  }
374
370
  }
@@ -380,6 +376,9 @@ export const withController = (config: MenuConfig) => component => {
380
376
  * Handles mouse leave from submenu
381
377
  */
382
378
  const handleSubmenuLeave = (e: MouseEvent): void => {
379
+ // If keyboard navigation is active, don't close submenu on mouse leave
380
+ if (state.keyboardNavActive) return;
381
+
383
382
  // Clear hover intent
384
383
  clearHoverIntent();
385
384
 
@@ -397,7 +396,7 @@ export const withController = (config: MenuConfig) => component => {
397
396
  const overMenuItem = menuItemElement.matches(':hover');
398
397
 
399
398
  if (!overSubmenu && !overMenuItem) {
400
- closeSubmenu();
399
+ closeSubmenuAtLevel(state.submenuLevel);
401
400
  }
402
401
  }
403
402
 
@@ -406,22 +405,45 @@ export const withController = (config: MenuConfig) => component => {
406
405
  };
407
406
 
408
407
  /**
409
- * Opens a submenu
408
+ * Opens a submenu with proper animation and positioning
410
409
  */
411
410
  const openSubmenu = (item: MenuItem, index: number, itemElement: HTMLElement, viaKeyboard = false): void => {
412
411
  if (!item.submenu || !item.hasSubmenu) return;
413
412
 
414
- // Close any existing submenu
415
- closeSubmenu();
413
+ // If opened via keyboard, update the keyboard navigation state
414
+ if (viaKeyboard) {
415
+ state.keyboardNavActive = true;
416
+ }
417
+
418
+ // Get current level of the submenu we're opening
419
+ const currentLevel = itemElement.closest(`.${component.getClass('menu--submenu')}`)
420
+ ? parseInt(itemElement.closest(`.${component.getClass('menu--submenu')}`).getAttribute('data-level') || '0', 10) + 1
421
+ : 1;
422
+
423
+ // Close any deeper level submenus first, preserving the current level
424
+ closeSubmenuAtLevel(currentLevel);
425
+
426
+ // Check if this submenu is already in opening state - if so, do nothing
427
+ const existingSubmenuIndex = state.activeSubmenus.findIndex(
428
+ s => s.menuItem === itemElement && s.isOpening
429
+ );
430
+ if (existingSubmenuIndex >= 0) {
431
+ return; // Already opening this submenu, don't restart the process
432
+ }
416
433
 
417
434
  // Set expanded state
418
435
  itemElement.setAttribute('aria-expanded', 'true');
419
436
 
420
- // Create submenu element
437
+ // Create submenu element with proper classes and attributes
421
438
  const submenuElement = document.createElement('div');
422
439
  submenuElement.className = `${component.getClass('menu')} ${component.getClass('menu--submenu')}`;
423
440
  submenuElement.setAttribute('role', 'menu');
424
441
  submenuElement.setAttribute('tabindex', '-1');
442
+ submenuElement.setAttribute('data-level', currentLevel.toString());
443
+ submenuElement.setAttribute('data-parent-item', item.id);
444
+
445
+ // Increase z-index for each level of submenu
446
+ submenuElement.style.zIndex = `${1000 + (currentLevel * 10)}`;
425
447
 
426
448
  // Create submenu list
427
449
  const submenuList = document.createElement('ul');
@@ -442,58 +464,169 @@ export const withController = (config: MenuConfig) => component => {
442
464
  });
443
465
 
444
466
  submenuElement.appendChild(submenuList);
467
+
468
+ // Add to DOM to enable measurement and transitions
445
469
  document.body.appendChild(submenuElement);
446
470
 
471
+ // Position the submenu using our positioner with the current nesting level
472
+ positioner.positionSubmenu(submenuElement, itemElement, currentLevel);
473
+
447
474
  // Setup keyboard navigation for submenu
448
475
  submenuElement.addEventListener('keydown', handleMenuKeydown);
449
476
 
450
477
  // Add mouseenter event to prevent closing
451
478
  submenuElement.addEventListener('mouseenter', () => {
452
- clearSubmenuTimer();
479
+ if (!state.keyboardNavActive) {
480
+ clearSubmenuTimer();
481
+ }
453
482
  });
454
483
 
455
484
  // Add mouseleave event to handle closing
456
485
  submenuElement.addEventListener('mouseleave', (e) => {
457
- handleSubmenuLeave(e);
486
+ if (!state.keyboardNavActive) {
487
+ handleSubmenuLeave(e);
488
+ }
458
489
  });
459
490
 
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;
491
+ // Setup submenu event handlers for nested submenus
492
+ const setupNestedSubmenuHandlers = (parent: HTMLElement) => {
493
+ const submenuItems = parent.querySelectorAll(`.${component.getClass('menu-item--submenu')}`) as NodeListOf<HTMLElement>;
494
+
495
+ submenuItems.forEach((menuItem) => {
496
+ const itemIndex = parseInt(menuItem.getAttribute('data-index'), 10);
497
+ const menuItemData = item.submenu[itemIndex] as MenuItem;
498
+
499
+ if (menuItemData && menuItemData.hasSubmenu) {
500
+ // Add hover handler for nested submenus
501
+ if (config.openSubmenuOnHover) {
502
+ menuItem.addEventListener('mouseenter', () => {
503
+ handleNestedSubmenuHover(menuItemData, itemIndex, menuItem);
504
+ });
505
+ menuItem.addEventListener('mouseleave', handleSubmenuLeave);
506
+ }
507
+
508
+ // Add click handler for nested submenus
509
+ menuItem.addEventListener('click', (e) => {
510
+ e.preventDefault();
511
+ e.stopPropagation();
512
+ handleNestedSubmenuClick(menuItemData, itemIndex, menuItem, false);
513
+ });
514
+ }
515
+ });
516
+ };
470
517
 
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
- }
518
+ // Setup handlers for any nested submenus
519
+ setupNestedSubmenuHandlers(submenuElement);
476
520
 
477
- // Add to state
521
+ // Update state with active submenu
478
522
  state.activeSubmenu = submenuElement;
479
523
  state.activeSubmenuItem = itemElement;
480
524
 
481
- // Add submenu events
525
+ // Add to active submenus array to maintain hierarchy
526
+ state.activeSubmenus.push({
527
+ element: submenuElement,
528
+ menuItem: itemElement,
529
+ level: currentLevel,
530
+ isOpening: true // Mark as in opening transition
531
+ });
532
+
533
+ // Update submenu level
534
+ state.submenuLevel = currentLevel;
535
+
536
+ // Add document events for this submenu
482
537
  document.addEventListener('click', handleDocumentClickForSubmenu);
483
- window.addEventListener('resize', handleWindowResizeForSubmenu);
484
- window.addEventListener('scroll', handleWindowScrollForSubmenu);
538
+ window.addEventListener('resize', handleWindowResizeForSubmenu, { passive: true });
539
+ window.addEventListener('scroll', handleWindowScrollForSubmenu, { passive: true });
485
540
 
486
- // Make visible
487
- setTimeout(() => {
541
+ // Make visible with animation
542
+ requestAnimationFrame(() => {
488
543
  submenuElement.classList.add(`${component.getClass('menu--visible')}`);
489
544
 
545
+ // Wait for transition to complete before marking as fully opened
546
+ // This should match your CSS transition duration
547
+ setTimeout(() => {
548
+ // Find this submenu in the active submenus array and update its state
549
+ const index = state.activeSubmenus.findIndex(s => s.element === submenuElement);
550
+ if (index !== -1) {
551
+ state.activeSubmenus[index].isOpening = false;
552
+ }
553
+ }, 300); // Adjust to match your transition duration
554
+
490
555
  // If opened via keyboard, focus the first item in the submenu
491
556
  if (viaKeyboard && submenuItems.length > 0) {
557
+ submenuItems[0].setAttribute('tabindex', '0');
558
+
559
+ // Set other items to -1
560
+ for (let i = 1; i < submenuItems.length; i++) {
561
+ submenuItems[i].setAttribute('tabindex', '-1');
562
+ }
563
+
564
+ // Focus with a short delay to allow animation to start
492
565
  setTimeout(() => {
493
566
  submenuItems[0].focus();
494
567
  }, 50);
495
568
  }
496
- }, 10);
569
+ });
570
+ };
571
+
572
+ /**
573
+ * Handles hover on a nested submenu item
574
+ */
575
+ const handleNestedSubmenuHover = (item: MenuItem, index: number, itemElement: HTMLElement): void => {
576
+ if (!config.openSubmenuOnHover || !item.hasSubmenu || state.keyboardNavActive) return;
577
+
578
+ // Clear any existing timers
579
+ clearHoverIntent();
580
+ clearSubmenuTimer();
581
+
582
+ // Set hover intent with a slightly longer delay for nested menus
583
+ state.hoverIntent.activeItem = itemElement;
584
+ state.hoverIntent.timer = setTimeout(() => {
585
+ const isCurrentlyHovered = itemElement.matches(':hover');
586
+ if (isCurrentlyHovered) {
587
+ // Find the closest submenu level of this item
588
+ const currentLevel = parseInt(
589
+ itemElement.closest(`.${component.getClass('menu--submenu')}`)?.getAttribute('data-level') || '1',
590
+ 10
591
+ );
592
+
593
+ // Open the nested submenu (will handle closing deeper levels properly)
594
+ handleNestedSubmenuClick(item, index, itemElement, false);
595
+ }
596
+ state.hoverIntent.timer = null;
597
+ }, 120); // Slightly longer delay for nested submenus
598
+ };
599
+
600
+ /**
601
+ * Handles click on a nested submenu item
602
+ */
603
+ const handleNestedSubmenuClick = (item: MenuItem, index: number, itemElement: HTMLElement, viaKeyboard = false): void => {
604
+ if (!item.submenu || !item.hasSubmenu) return;
605
+
606
+ // Check if the submenu is already open
607
+ const isOpen = itemElement.getAttribute('aria-expanded') === 'true';
608
+
609
+ // Find if any submenu is currently in opening transition
610
+ const anySubmenuTransitioning = state.activeSubmenus.some(s => s.isOpening);
611
+
612
+ // Completely ignore clicks during any submenu transition
613
+ if (anySubmenuTransitioning) {
614
+ return;
615
+ }
616
+
617
+ if (isOpen) {
618
+ // Find the closest submenu level
619
+ const currentLevel = parseInt(
620
+ itemElement.closest(`.${component.getClass('menu--submenu')}`)?.getAttribute('data-level') || '1',
621
+ 10
622
+ );
623
+
624
+ // Close submenus at and deeper than the next level
625
+ closeSubmenuAtLevel(currentLevel + 1);
626
+ } else {
627
+ // Open the nested submenu
628
+ openSubmenu(item, index, itemElement, viaKeyboard);
629
+ }
497
630
  };
498
631
 
499
632
  /**
@@ -507,36 +640,94 @@ export const withController = (config: MenuConfig) => component => {
507
640
  };
508
641
 
509
642
  /**
510
- * Closes any open submenu
643
+ * Closes submenus at or deeper than the specified level
644
+ * @param level - The level to start closing from
511
645
  */
512
- const closeSubmenu = (): void => {
513
- // Clear timers
646
+ const closeSubmenuAtLevel = (level: number): void => {
647
+ // Clear any hover intent or submenu timers
514
648
  clearHoverIntent();
515
649
  clearSubmenuTimer();
516
650
 
517
- if (!state.activeSubmenu) return;
651
+ // Find submenus at or deeper than the specified level
652
+ const submenusCopy = [...state.activeSubmenus];
653
+ const submenuIndicesToRemove = [];
518
654
 
519
- // Remove expanded state from all items
520
- if (state.activeSubmenuItem) {
521
- state.activeSubmenuItem.setAttribute('aria-expanded', 'false');
655
+ // Identify which submenus to remove, working from deepest level first
656
+ for (let i = submenusCopy.length - 1; i >= 0; i--) {
657
+ if (submenusCopy[i].level >= level) {
658
+ const submenuToClose = submenusCopy[i];
659
+
660
+ // Set aria-expanded attribute to false on the parent menu item
661
+ if (submenuToClose.menuItem) {
662
+ submenuToClose.menuItem.setAttribute('aria-expanded', 'false');
663
+ }
664
+
665
+ // Hide with animation
666
+ submenuToClose.element.classList.remove(`${component.getClass('menu--visible')}`);
667
+
668
+ // Schedule for removal
669
+ setTimeout(() => {
670
+ if (submenuToClose.element.parentNode) {
671
+ submenuToClose.element.parentNode.removeChild(submenuToClose.element);
672
+ }
673
+ }, 200);
674
+
675
+ // Mark for removal from state
676
+ submenuIndicesToRemove.push(i);
677
+ }
522
678
  }
523
679
 
524
- // Remove submenu element with animation
525
- state.activeSubmenu.classList.remove(`${component.getClass('menu--visible')}`);
680
+ // Remove the closed submenus from state
681
+ submenuIndicesToRemove.forEach(index => {
682
+ state.activeSubmenus.splice(index, 1);
683
+ });
684
+
685
+ // Update active submenu references based on what's left
686
+ if (state.activeSubmenus.length > 0) {
687
+ const deepestRemaining = state.activeSubmenus[state.activeSubmenus.length - 1];
688
+ state.activeSubmenu = deepestRemaining.element;
689
+ state.activeSubmenuItem = deepestRemaining.menuItem;
690
+ state.submenuLevel = deepestRemaining.level;
691
+ } else {
692
+ state.activeSubmenu = null;
693
+ state.activeSubmenuItem = null;
694
+ state.submenuLevel = 0;
695
+ }
696
+ };
697
+
698
+ /**
699
+ * Closes all submenus
700
+ */
701
+ const closeSubmenu = (): void => {
702
+ // Clear timers
703
+ clearHoverIntent();
704
+ clearSubmenuTimer();
526
705
 
527
- // Store reference for cleanup
528
- const submenuToRemove = state.activeSubmenu;
706
+ if (state.activeSubmenus.length === 0) return;
529
707
 
530
- // Remove after animation
531
- setTimeout(() => {
532
- if (submenuToRemove && submenuToRemove.parentNode) {
533
- submenuToRemove.parentNode.removeChild(submenuToRemove);
708
+ // Close all active submenus
709
+ [...state.activeSubmenus].forEach(submenu => {
710
+ // Remove expanded state from parent item
711
+ if (submenu.menuItem) {
712
+ submenu.menuItem.setAttribute('aria-expanded', 'false');
534
713
  }
535
- }, 200);
714
+
715
+ // Remove submenu element with animation
716
+ submenu.element.classList.remove(`${component.getClass('menu--visible')}`);
717
+
718
+ // Remove after animation
719
+ setTimeout(() => {
720
+ if (submenu.element.parentNode) {
721
+ submenu.element.parentNode.removeChild(submenu.element);
722
+ }
723
+ }, 200);
724
+ });
536
725
 
537
726
  // Clear state
538
727
  state.activeSubmenu = null;
539
728
  state.activeSubmenuItem = null;
729
+ state.activeSubmenus = [];
730
+ state.submenuLevel = 0;
540
731
 
541
732
  // Remove document events
542
733
  document.removeEventListener('click', handleDocumentClickForSubmenu);
@@ -567,56 +758,96 @@ export const withController = (config: MenuConfig) => component => {
567
758
  * Handles window resize for submenu
568
759
  */
569
760
  const handleWindowResizeForSubmenu = (): void => {
570
- // Reposition or close submenu on resize
571
- closeSubmenu();
761
+ // Reposition open submenu on resize
762
+ if (state.activeSubmenu && state.activeSubmenuItem) {
763
+ positioner.positionSubmenu(state.activeSubmenu, state.activeSubmenuItem, state.submenuLevel);
764
+ }
572
765
  };
573
766
 
574
767
  /**
575
768
  * Handles window scroll for submenu
769
+ * Repositions the submenu to stay attached to its parent during scrolling
576
770
  */
577
771
  const handleWindowScrollForSubmenu = (): void => {
578
- // Reposition or close submenu on scroll
579
- closeSubmenu();
772
+ // Use requestAnimationFrame to optimize scroll performance
773
+ window.requestAnimationFrame(() => {
774
+ // Only reposition if we have an active submenu
775
+ if (state.activeSubmenu && state.activeSubmenuItem) {
776
+ positioner.positionSubmenu(state.activeSubmenu, state.activeSubmenuItem, state.submenuLevel);
777
+ }
778
+ });
580
779
  };
581
780
 
582
781
  /**
583
782
  * Opens the menu
783
+ * @param {Event} [event] - Optional event that triggered the open
784
+ * @param {'mouse'|'keyboard'} [interactionType='mouse'] - Type of interaction that triggered the open
584
785
  */
585
- /**
586
- * Opens the menu
587
- */
588
- const openMenu = (event?: Event): void => {
786
+ const openMenu = (event?: Event, interactionType: 'mouse' | 'keyboard' = 'mouse'): void => {
589
787
  if (state.visible) return;
590
788
 
789
+ // Set keyboard navigation state based on interaction type
790
+ state.keyboardNavActive = interactionType === 'keyboard';
791
+
591
792
  // Update state
592
793
  state.visible = true;
593
794
 
594
- // Add the menu to the DOM if it's not already there
795
+ // First, remove any existing document click listener
796
+ document.removeEventListener('click', handleDocumentClick);
797
+
798
+ // Step 1: Add the menu to the DOM if it's not already there with initial hidden state
595
799
  if (!component.element.parentNode) {
800
+ // Apply explicit initial styling to ensure it doesn't flash
801
+ component.element.classList.remove(`${component.getClass('menu--visible')}`);
802
+ component.element.setAttribute('aria-hidden', 'true');
803
+ component.element.style.transform = 'scaleY(0)';
804
+ component.element.style.opacity = '0';
805
+
806
+ // Add to DOM
596
807
  document.body.appendChild(component.element);
597
808
  }
598
809
 
599
- // Set attributes
600
- component.element.setAttribute('aria-hidden', 'false');
601
- component.element.classList.add(`${component.getClass('menu--visible')}`);
602
-
603
- // Position
604
- positionMenu();
810
+ // Step 2: Position the menu (will be invisible)
811
+ const anchorElement = getAnchorElement();
812
+ if (anchorElement) {
813
+ positioner.positionMenu(anchorElement);
814
+ }
605
815
 
606
- // Focus first item
816
+ // Step 3: Use a small delay to ensure DOM operations are complete
607
817
  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);
818
+ // Set attributes for accessibility
819
+ component.element.setAttribute('aria-hidden', 'false');
820
+
821
+ // Remove the inline styles we added
822
+ component.element.style.transform = '';
823
+ component.element.style.opacity = '';
824
+
825
+ // Force a reflow before adding the visible class
826
+ void component.element.getBoundingClientRect();
827
+
828
+ // Add visible class to start the CSS transition
829
+ component.element.classList.add(`${component.getClass('menu--visible')}`);
830
+
831
+ // Step 4: Focus based on interaction type (after animation starts)
832
+ setTimeout(() => {
833
+ handleFocus(interactionType);
834
+ }, 100);
835
+
836
+ // Add the document click handler on the next event loop
837
+ // after the current click is fully processed
838
+ setTimeout(() => {
839
+ if (config.closeOnClickOutside && state.visible) {
840
+ document.addEventListener('click', handleDocumentClick);
841
+ }
842
+
843
+ // Add other document events normally
844
+ if (config.closeOnEscape) {
845
+ document.addEventListener('keydown', handleDocumentKeydown);
846
+ }
847
+ window.addEventListener('resize', handleWindowResize, { passive: true });
848
+ window.addEventListener('scroll', handleWindowScroll, { passive: true });
849
+ }, 0);
850
+ }, 20); // Short delay for browser to process
620
851
 
621
852
  // Trigger event
622
853
  eventHelpers.triggerEvent('open', {}, event);
@@ -624,10 +855,22 @@ export const withController = (config: MenuConfig) => component => {
624
855
 
625
856
  /**
626
857
  * Closes the menu
858
+ * @param {Event} [event] - Optional event that triggered the close
859
+ * @param {boolean} [restoreFocus=true] - Whether to restore focus to the anchor element
860
+ * @param {boolean} [skipAnimation=false] - Whether to skip animation (for focus changes)
627
861
  */
628
- const closeMenu = (event?: Event): void => {
862
+ const closeMenu = (event?: Event, restoreFocus: boolean = true, skipAnimation: boolean = false): void => {
629
863
  if (!state.visible) return;
630
864
 
865
+ // Check if we're in a tab navigation - if so, don't restore focus
866
+ const isTabNavigation = document.body.hasAttribute('data-menu-tab-navigation');
867
+ if (isTabNavigation) {
868
+ restoreFocus = false;
869
+ }
870
+
871
+ // Reset keyboard navigation state on close
872
+ state.keyboardNavActive = false;
873
+
631
874
  // Close any open submenu first
632
875
  closeSubmenu();
633
876
 
@@ -638,38 +881,99 @@ export const withController = (config: MenuConfig) => component => {
638
881
  component.element.setAttribute('aria-hidden', 'true');
639
882
  component.element.classList.remove(`${component.getClass('menu--visible')}`);
640
883
 
884
+ // Store anchor reference before potentially removing the menu
885
+ const anchorElement = getAnchorElement();
886
+
641
887
  // Remove document events
642
888
  document.removeEventListener('click', handleDocumentClick);
643
889
  document.removeEventListener('keydown', handleDocumentKeydown);
644
890
  window.removeEventListener('resize', handleWindowResize);
645
891
  window.removeEventListener('scroll', handleWindowScroll);
646
892
 
647
- // Trigger event
648
- eventHelpers.triggerEvent('close', {}, event);
893
+ // Trigger event with added data
894
+ eventHelpers.triggerEvent('close', {
895
+ isFocusRelated: event instanceof FocusEvent,
896
+ shouldRestoreFocus: restoreFocus,
897
+ isTabNavigation: isTabNavigation || event?.key === 'Tab'
898
+ }, event);
649
899
 
650
- // Remove from DOM after animation completes
900
+ // Determine animation duration - for tab navigation we want to close immediately
901
+ const animationDuration = skipAnimation ? 0 : 300;
902
+
903
+ // Remove from DOM after animation completes (or immediately if skipAnimation)
651
904
  setTimeout(() => {
652
905
  if (component.element.parentNode && !state.visible) {
653
906
  component.element.parentNode.removeChild(component.element);
907
+
908
+ // Only restore focus if explicitly requested AND not in tab navigation
909
+ if (restoreFocus && anchorElement && !isTabNavigation && event?.type !== 'click') {
910
+ // Additional check to make sure we're not in an ongoing tab navigation
911
+ if (!document.body.hasAttribute('data-menu-tab-navigation')) {
912
+ requestAnimationFrame(() => {
913
+ anchorElement.focus();
914
+ });
915
+ }
916
+ }
654
917
  }
655
- }, 300); // Match the animation duration in CSS
918
+ }, animationDuration);
656
919
  };
657
920
 
658
921
  /**
659
922
  * Toggles the menu
660
923
  */
661
- const toggleMenu = (event?: Event): void => {
924
+ const toggleMenu = (event?: Event, interactionType: 'mouse' | 'keyboard' = 'mouse'): void => {
662
925
  if (state.visible) {
663
926
  closeMenu(event);
664
927
  } else {
665
- openMenu(event);
928
+ // Determine interaction type from event
929
+ if (event) {
930
+ if (event instanceof KeyboardEvent) {
931
+ interactionType = 'keyboard';
932
+ } else if (event instanceof MouseEvent) {
933
+ interactionType = 'mouse';
934
+ }
935
+ }
936
+ openMenu(event, interactionType);
666
937
  }
667
938
  };
668
939
 
940
+ /**
941
+ * Updates the selected state of menu items
942
+ * @param itemId - The ID of the item to mark as selected, or null to clear selection
943
+ */
944
+ const updateSelectedState = (itemId: string | null): void => {
945
+ if (!component.element) return;
946
+
947
+ // Get all menu items
948
+ const menuItems = component.element.querySelectorAll(`.${component.getClass('menu-item')}`) as NodeListOf<HTMLElement>;
949
+
950
+ // Update selected state for each item
951
+ menuItems.forEach(item => {
952
+ const currentItemId = item.getAttribute('data-id');
953
+
954
+ if (currentItemId === itemId) {
955
+ item.classList.add(`${component.getClass('menu-item--selected')}`);
956
+ item.setAttribute('aria-selected', 'true');
957
+ } else {
958
+ item.classList.remove(`${component.getClass('menu-item--selected')}`);
959
+ item.setAttribute('aria-selected', 'false');
960
+ }
961
+ });
962
+
963
+ // Also update state
964
+ state.selectedItemId = itemId;
965
+ };
966
+
669
967
  /**
670
968
  * Handles document click
671
969
  */
672
970
  const handleDocumentClick = (e: MouseEvent): void => {
971
+ // If we should ignore this click (happens right after opening), reset the flag and return
972
+ if (ignoreNextDocumentClick) {
973
+ ignoreNextDocumentClick = false;
974
+ return;
975
+ }
976
+
673
977
  // Don't close if clicked inside menu
674
978
  if (component.element.contains(e.target as Node)) {
675
979
  return;
@@ -690,7 +994,8 @@ export const withController = (config: MenuConfig) => component => {
690
994
  */
691
995
  const handleDocumentKeydown = (e: KeyboardEvent): void => {
692
996
  if (e.key === 'Escape') {
693
- closeMenu(e);
997
+ // When closing with Escape, always restore focus
998
+ closeMenu(e, true);
694
999
  }
695
1000
  };
696
1001
 
@@ -699,37 +1004,42 @@ export const withController = (config: MenuConfig) => component => {
699
1004
  */
700
1005
  const handleWindowResize = (): void => {
701
1006
  if (state.visible) {
702
- positionMenu();
1007
+ const anchorElement = getAnchorElement();
1008
+ if (anchorElement) {
1009
+ positioner.positionMenu(anchorElement);
1010
+ }
703
1011
  }
704
1012
  };
705
1013
 
706
1014
  /**
707
1015
  * Handles window scroll
1016
+ * Repositions the menu to stay attached to its anchor during scrolling
708
1017
  */
709
1018
  const handleWindowScroll = (): void => {
710
1019
  if (state.visible) {
711
- positionMenu();
1020
+ // Use requestAnimationFrame to optimize scroll performance
1021
+ window.requestAnimationFrame(() => {
1022
+ // Reposition the main menu to stay attached to anchor when scrolling
1023
+ const anchorElement = getAnchorElement();
1024
+ if (anchorElement) {
1025
+ positioner.positionMenu(anchorElement);
1026
+ }
1027
+
1028
+ // Also reposition any open submenu relative to its parent menu item
1029
+ if (state.activeSubmenu && state.activeSubmenuItem) {
1030
+ positioner.positionSubmenu(state.activeSubmenu, state.activeSubmenuItem, state.submenuLevel);
1031
+ }
1032
+ });
712
1033
  }
713
1034
  };
714
1035
 
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
1036
  /**
730
1037
  * Handles keydown events on the menu or submenu
731
1038
  */
732
1039
  const handleMenuKeydown = (e: KeyboardEvent): void => {
1040
+ // Set keyboard navigation active flag
1041
+ state.keyboardNavActive = true;
1042
+
733
1043
  // Determine if this event is from the main menu or a submenu
734
1044
  const isSubmenu = state.activeSubmenu && state.activeSubmenu.contains(e.target as Node);
735
1045
 
@@ -750,17 +1060,28 @@ export const withController = (config: MenuConfig) => component => {
750
1060
  focusedItemIndex = items.indexOf(focusedElement);
751
1061
  }
752
1062
 
1063
+ // Function to update tabindex and focus a specific item
1064
+ const focusItem = (index: number) => {
1065
+ // Set all items to tabindex -1
1066
+ items.forEach(item => item.setAttribute('tabindex', '-1'));
1067
+
1068
+ // Set the target item to tabindex 0 and focus it
1069
+ items[index].setAttribute('tabindex', '0');
1070
+ items[index].focus();
1071
+ };
1072
+
753
1073
  switch (e.key) {
754
1074
  case 'ArrowDown':
755
1075
  case 'Down':
756
1076
  e.preventDefault();
757
1077
  // If no item is active, select the first one
758
1078
  if (focusedItemIndex < 0) {
759
- items[0].focus();
1079
+ focusItem(0);
760
1080
  } else if (focusedItemIndex < items.length - 1) {
761
- items[focusedItemIndex + 1].focus();
1081
+ focusItem(focusedItemIndex + 1);
762
1082
  } else {
763
- items[0].focus();
1083
+ // Wrap to first item
1084
+ focusItem(0);
764
1085
  }
765
1086
  break;
766
1087
 
@@ -769,22 +1090,23 @@ export const withController = (config: MenuConfig) => component => {
769
1090
  e.preventDefault();
770
1091
  // If no item is active, select the last one
771
1092
  if (focusedItemIndex < 0) {
772
- items[items.length - 1].focus();
1093
+ focusItem(items.length - 1);
773
1094
  } else if (focusedItemIndex > 0) {
774
- items[focusedItemIndex - 1].focus();
1095
+ focusItem(focusedItemIndex - 1);
775
1096
  } else {
776
- items[items.length - 1].focus();
1097
+ // Wrap to last item
1098
+ focusItem(items.length - 1);
777
1099
  }
778
1100
  break;
779
1101
 
780
1102
  case 'Home':
781
1103
  e.preventDefault();
782
- items[0].focus();
1104
+ focusItem(0);
783
1105
  break;
784
1106
 
785
1107
  case 'End':
786
1108
  e.preventDefault();
787
- items[items.length - 1].focus();
1109
+ focusItem(items.length - 1);
788
1110
  break;
789
1111
 
790
1112
  case 'Enter':
@@ -803,7 +1125,20 @@ export const withController = (config: MenuConfig) => component => {
803
1125
  if (isSubmenu) {
804
1126
  // In a submenu, right arrow opens nested submenus
805
1127
  if (focusedItemIndex >= 0 && items[focusedItemIndex].classList.contains(`${component.getClass('menu-item--submenu')}`)) {
806
- items[focusedItemIndex].click();
1128
+ // Simulate click but specifying it's via keyboard
1129
+ const itemElement = items[focusedItemIndex];
1130
+ const itemIndex = parseInt(itemElement.getAttribute('data-index'), 10);
1131
+
1132
+ // Get the parent submenu to find the correct data
1133
+ const parentMenu = itemElement.closest(`.${component.getClass('menu--submenu')}`);
1134
+ const parentItemId = parentMenu?.getAttribute('data-parent-item');
1135
+
1136
+ // Find the parent item in the items array to get its submenu
1137
+ const parentItem = findItemById(parentItemId);
1138
+ if (parentItem && parentItem.submenu) {
1139
+ const itemData = parentItem.submenu[itemIndex] as MenuItem;
1140
+ handleNestedSubmenuClick(itemData, itemIndex, itemElement, true);
1141
+ }
807
1142
  }
808
1143
  } else {
809
1144
  // In main menu, right arrow opens a submenu
@@ -828,9 +1163,21 @@ export const withController = (config: MenuConfig) => component => {
828
1163
  if (state.activeSubmenuItem) {
829
1164
  // Store the reference to the parent item before closing the submenu
830
1165
  const parentItem = state.activeSubmenuItem;
831
- closeSubmenu();
1166
+
1167
+ // Get the current level
1168
+ const currentLevel = parseInt(
1169
+ menuElement.getAttribute('data-level') || '1',
1170
+ 10
1171
+ );
1172
+
1173
+ // Close this level of submenu
1174
+ closeSubmenuAtLevel(currentLevel);
1175
+
832
1176
  // Focus the parent item after closing
833
- parentItem.focus();
1177
+ if (parentItem) {
1178
+ parentItem.setAttribute('tabindex', '0');
1179
+ parentItem.focus();
1180
+ }
834
1181
  } else {
835
1182
  closeSubmenu();
836
1183
  }
@@ -844,27 +1191,116 @@ export const withController = (config: MenuConfig) => component => {
844
1191
  if (state.activeSubmenuItem) {
845
1192
  // Store the reference to the parent item before closing the submenu
846
1193
  const parentItem = state.activeSubmenuItem;
847
- closeSubmenu();
1194
+
1195
+ // Get the current level
1196
+ const currentLevel = parseInt(
1197
+ menuElement.getAttribute('data-level') || '1',
1198
+ 10
1199
+ );
1200
+
1201
+ // Close this level of submenu
1202
+ closeSubmenuAtLevel(currentLevel);
1203
+
848
1204
  // Focus the parent item after closing
849
- parentItem.focus();
1205
+ if (parentItem) {
1206
+ parentItem.setAttribute('tabindex', '0');
1207
+ parentItem.focus();
1208
+ }
850
1209
  } else {
851
1210
  closeSubmenu();
852
1211
  }
853
1212
  } else {
854
- // In main menu, Escape closes the entire menu
855
- closeMenu(e);
1213
+ // In main menu, Escape closes the entire menu and restores focus to anchor
1214
+ closeMenu(e, true);
856
1215
  }
857
1216
  break;
858
1217
 
859
1218
  case 'Tab':
860
- // Close the menu when tabbing out
861
- if (!isSubmenu) {
862
- closeMenu();
1219
+ // Modified Tab handling - we want to close the menu and move focus to the next focusable element
1220
+ e.preventDefault(); // Prevent default tab behavior
1221
+
1222
+ // Find the focusable elements before closing the menu
1223
+ const focusableElements = getFocusableElements();
1224
+ const anchorElement = getAnchorElement();
1225
+ const anchorIndex = anchorElement ? focusableElements.indexOf(anchorElement) : -1;
1226
+
1227
+ // Calculate the next element to focus
1228
+ let nextElementIndex = -1;
1229
+ if (anchorIndex >= 0) {
1230
+ nextElementIndex = e.shiftKey ?
1231
+ // For Shift+Tab, go to previous element or last element if we're at the start
1232
+ (anchorIndex > 0 ? anchorIndex - 1 : focusableElements.length - 1) :
1233
+ // For Tab, go to next element or first element if we're at the end
1234
+ (anchorIndex < focusableElements.length - 1 ? anchorIndex + 1 : 0);
1235
+ }
1236
+
1237
+ // Store the next element to focus before closing the menu
1238
+ const nextElementToFocus = nextElementIndex >= 0 ? focusableElements[nextElementIndex] : null;
1239
+
1240
+ // Create a flag that prevents focus restoration
1241
+ const tabNavigationInProgress = true;
1242
+
1243
+ // Close the menu with focus restoration explicitly disabled
1244
+ closeMenu(e, false, true);
1245
+
1246
+ // Focus the next element if found, with a slight delay to ensure menu is closed
1247
+ if (nextElementToFocus) {
1248
+ // Use setTimeout with a very small delay to ensure this happens after all other operations
1249
+ setTimeout(() => {
1250
+ // Set a flag to prevent any other focus management from interfering
1251
+ document.body.setAttribute('data-menu-tab-navigation', 'true');
1252
+
1253
+ // Focus the element
1254
+ nextElementToFocus.focus();
1255
+
1256
+ // Remove the flag after focus is set
1257
+ setTimeout(() => {
1258
+ document.body.removeAttribute('data-menu-tab-navigation');
1259
+ }, 100);
1260
+ }, 10);
863
1261
  }
864
1262
  break;
865
1263
  }
866
1264
  };
867
1265
 
1266
+ /**
1267
+ * Gets all focusable elements in the document
1268
+ * Useful for Tab navigation management
1269
+ */
1270
+ const getFocusableElements = (): HTMLElement[] => {
1271
+ // Query all potentially focusable elements
1272
+ const focusableElementsString = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
1273
+ const elements = document.querySelectorAll(focusableElementsString) as NodeListOf<HTMLElement>;
1274
+
1275
+ // Convert to array and filter out hidden elements
1276
+ return Array.from(elements).filter(element => {
1277
+ return element.offsetParent !== null && !element.classList.contains('hidden');
1278
+ });
1279
+ };
1280
+
1281
+ /**
1282
+ * Find a menu item by its ID in the items array
1283
+ */
1284
+ const findItemById = (id: string): MenuItem | null => {
1285
+ // Search in top-level items
1286
+ for (const item of state.items) {
1287
+ if ('id' in item && item.id === id) {
1288
+ return item as MenuItem;
1289
+ }
1290
+
1291
+ // Search in submenu items
1292
+ if ('submenu' in item && Array.isArray((item as MenuItem).submenu)) {
1293
+ for (const subItem of (item as MenuItem).submenu) {
1294
+ if ('id' in subItem && subItem.id === id) {
1295
+ return subItem as MenuItem;
1296
+ }
1297
+ }
1298
+ }
1299
+ }
1300
+
1301
+ return null;
1302
+ };
1303
+
868
1304
  /**
869
1305
  * Sets up the menu
870
1306
  */
@@ -877,7 +1313,10 @@ export const withController = (config: MenuConfig) => component => {
877
1313
 
878
1314
  // Position if visible
879
1315
  if (state.visible) {
880
- positionMenu();
1316
+ const anchorElement = getAnchorElement();
1317
+ if (anchorElement) {
1318
+ positioner.positionMenu(anchorElement);
1319
+ }
881
1320
 
882
1321
  // Show immediately
883
1322
  component.element.classList.add(`${component.getClass('menu--visible')}`);
@@ -917,8 +1356,12 @@ export const withController = (config: MenuConfig) => component => {
917
1356
  window.removeEventListener('scroll', handleWindowScrollForSubmenu);
918
1357
 
919
1358
  // Clean up submenu element
920
- if (state.activeSubmenu && state.activeSubmenu.parentNode) {
921
- state.activeSubmenu.parentNode.removeChild(state.activeSubmenu);
1359
+ if (state.activeSubmenus.length > 0) {
1360
+ state.activeSubmenus.forEach(submenu => {
1361
+ if (submenu.element.parentNode) {
1362
+ submenu.element.parentNode.removeChild(submenu.element);
1363
+ }
1364
+ });
922
1365
  }
923
1366
 
924
1367
  originalDestroy();
@@ -929,20 +1372,20 @@ export const withController = (config: MenuConfig) => component => {
929
1372
  return {
930
1373
  ...component,
931
1374
  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
- },
1375
+ open: (event, interactionType = 'mouse') => {
1376
+ openMenu(event, interactionType);
1377
+ return component;
1378
+ },
1379
+
1380
+ close: (event, restoreFocus = true, skipAnimation = false) => {
1381
+ closeMenu(event, restoreFocus, skipAnimation);
1382
+ return component;
1383
+ },
1384
+
1385
+ toggle: (event, interactionType = 'mouse') => {
1386
+ toggleMenu(event, interactionType);
1387
+ return component;
1388
+ },
946
1389
 
947
1390
  isOpen: () => state.visible,
948
1391
 
@@ -954,15 +1397,25 @@ export const withController = (config: MenuConfig) => component => {
954
1397
 
955
1398
  getItems: () => state.items,
956
1399
 
957
- setPlacement: (placement) => {
958
- state.placement = placement;
1400
+ setPosition: (position) => {
1401
+ state.position = position;
959
1402
  if (state.visible) {
960
- positionMenu();
1403
+ const anchorElement = getAnchorElement();
1404
+ if (anchorElement) {
1405
+ positioner.positionMenu(anchorElement);
1406
+ }
961
1407
  }
962
1408
  return component;
963
1409
  },
964
1410
 
965
- getPlacement: () => state.placement
1411
+ getPosition: () => state.position,
1412
+
1413
+ setSelected: (itemId: string | null) => {
1414
+ updateSelectedState(itemId);
1415
+ return component;
1416
+ },
1417
+
1418
+ getSelected: () => state.selectedItemId
966
1419
  }
967
1420
  };
968
1421
  };