mtrl 0.3.6 → 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 (45) 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 +15 -13
  5. package/src/components/menu/config.ts +5 -5
  6. package/src/components/menu/features/anchor.ts +99 -15
  7. package/src/components/menu/features/controller.ts +418 -221
  8. package/src/components/menu/features/index.ts +2 -1
  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 +18 -60
  12. package/src/components/menu/types.ts +17 -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 +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/index.ts +4 -1
  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 +103 -24
  38. package/src/styles/components/_select.scss +265 -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,7 @@
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';
4
5
 
5
6
  /**
6
7
  * Adds controller functionality to the menu component
@@ -19,10 +20,17 @@ export const withController = (config: MenuConfig) => component => {
19
20
  const state = {
20
21
  visible: config.visible || false,
21
22
  items: config.items || [],
22
- placement: config.placement,
23
- activeSubmenu: null,
24
- activeSubmenuItem: null,
23
+ position: config.position,
24
+ activeSubmenu: null as HTMLElement,
25
+ activeSubmenuItem: null as HTMLElement,
25
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
+ }>,
26
34
  submenuTimer: null,
27
35
  hoverIntent: {
28
36
  timer: null,
@@ -31,6 +39,9 @@ export const withController = (config: MenuConfig) => component => {
31
39
  component
32
40
  };
33
41
 
42
+ // Create positioner
43
+ const positioner = createPositioner(component, config);
44
+
34
45
  // Create event helpers
35
46
  const eventHelpers = {
36
47
  triggerEvent(eventName: string, data: any = {}, originalEvent?: Event) {
@@ -51,6 +62,12 @@ export const withController = (config: MenuConfig) => component => {
51
62
  * Gets the anchor element from config
52
63
  */
53
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
54
71
  const { anchor } = config;
55
72
 
56
73
  if (typeof anchor === 'string') {
@@ -62,7 +79,13 @@ export const withController = (config: MenuConfig) => component => {
62
79
  return element as HTMLElement;
63
80
  }
64
81
 
65
- return anchor;
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;
66
89
  };
67
90
 
68
91
  /**
@@ -74,7 +97,7 @@ export const withController = (config: MenuConfig) => component => {
74
97
 
75
98
  itemElement.className = itemClass;
76
99
  itemElement.setAttribute('role', 'menuitem');
77
- itemElement.setAttribute('tabindex', '-1');
100
+ itemElement.setAttribute('tabindex', '-1'); // Set to -1 by default, will update when needed
78
101
  itemElement.setAttribute('data-id', item.id);
79
102
  itemElement.setAttribute('data-index', index.toString());
80
103
 
@@ -121,8 +144,22 @@ export const withController = (config: MenuConfig) => component => {
121
144
 
122
145
  // Add event listeners
123
146
  if (!item.disabled) {
147
+ // Mouse events
124
148
  itemElement.addEventListener('click', (e) => handleItemClick(e, item, index));
125
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
+
126
163
  if (item.hasSubmenu && config.openSubmenuOnHover) {
127
164
  itemElement.addEventListener('mouseenter', () => handleSubmenuHover(item, index, itemElement));
128
165
  itemElement.addEventListener('mouseleave', handleSubmenuLeave);
@@ -170,128 +207,6 @@ export const withController = (config: MenuConfig) => component => {
170
207
  component.element.appendChild(menuList);
171
208
  };
172
209
 
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
210
  /**
296
211
  * Clean up hover intent timer
297
212
  */
@@ -303,6 +218,39 @@ export const withController = (config: MenuConfig) => component => {
303
218
  }
304
219
  };
305
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
+
306
254
  /**
307
255
  * Handles click on a menu item
308
256
  */
@@ -337,15 +285,31 @@ export const withController = (config: MenuConfig) => component => {
337
285
  const handleSubmenuClick = (item: MenuItem, index: number, itemElement: HTMLElement, viaKeyboard = false): void => {
338
286
  if (!item.submenu || !item.hasSubmenu) return;
339
287
 
288
+ // Check if the submenu is already open
340
289
  const isOpen = itemElement.getAttribute('aria-expanded') === 'true';
341
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
+
342
299
  if (isOpen) {
343
- // Close submenu
344
- closeSubmenu();
345
- } else {
346
- // Close any open submenu
347
- closeSubmenu();
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);
348
309
 
310
+ // Reset expanded state
311
+ itemElement.setAttribute('aria-expanded', 'false');
312
+ } else {
349
313
  // Open new submenu
350
314
  openSubmenu(item, index, itemElement, viaKeyboard);
351
315
  }
@@ -368,7 +332,6 @@ export const withController = (config: MenuConfig) => component => {
368
332
  if (isCurrentlyHovered) {
369
333
  // Only close and reopen if this is a different submenu item
370
334
  if (state.activeSubmenuItem !== itemElement) {
371
- closeSubmenu();
372
335
  openSubmenu(item, index, itemElement);
373
336
  }
374
337
  }
@@ -397,7 +360,7 @@ export const withController = (config: MenuConfig) => component => {
397
360
  const overMenuItem = menuItemElement.matches(':hover');
398
361
 
399
362
  if (!overSubmenu && !overMenuItem) {
400
- closeSubmenu();
363
+ closeSubmenuAtLevel(state.submenuLevel);
401
364
  }
402
365
  }
403
366
 
@@ -406,22 +369,40 @@ export const withController = (config: MenuConfig) => component => {
406
369
  };
407
370
 
408
371
  /**
409
- * Opens a submenu
372
+ * Opens a submenu with proper animation and positioning
410
373
  */
411
374
  const openSubmenu = (item: MenuItem, index: number, itemElement: HTMLElement, viaKeyboard = false): void => {
412
375
  if (!item.submenu || !item.hasSubmenu) return;
413
376
 
414
- // Close any existing submenu
415
- closeSubmenu();
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
+ }
416
392
 
417
393
  // Set expanded state
418
394
  itemElement.setAttribute('aria-expanded', 'true');
419
395
 
420
- // Create submenu element
396
+ // Create submenu element with proper classes and attributes
421
397
  const submenuElement = document.createElement('div');
422
398
  submenuElement.className = `${component.getClass('menu')} ${component.getClass('menu--submenu')}`;
423
399
  submenuElement.setAttribute('role', 'menu');
424
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)}`;
425
406
 
426
407
  // Create submenu list
427
408
  const submenuList = document.createElement('ul');
@@ -442,8 +423,13 @@ export const withController = (config: MenuConfig) => component => {
442
423
  });
443
424
 
444
425
  submenuElement.appendChild(submenuList);
426
+
427
+ // Add to DOM to enable measurement and transitions
445
428
  document.body.appendChild(submenuElement);
446
429
 
430
+ // Position the submenu using our positioner with the current nesting level
431
+ positioner.positionSubmenu(submenuElement, itemElement, currentLevel);
432
+
447
433
  // Setup keyboard navigation for submenu
448
434
  submenuElement.addEventListener('keydown', handleMenuKeydown);
449
435
 
@@ -457,43 +443,137 @@ export const withController = (config: MenuConfig) => component => {
457
443
  handleSubmenuLeave(e);
458
444
  });
459
445
 
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;
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
+ };
470
472
 
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
- }
473
+ // Setup handlers for any nested submenus
474
+ setupNestedSubmenuHandlers(submenuElement);
476
475
 
477
- // Add to state
476
+ // Update state with active submenu
478
477
  state.activeSubmenu = submenuElement;
479
478
  state.activeSubmenuItem = itemElement;
480
479
 
481
- // Add submenu events
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
482
492
  document.addEventListener('click', handleDocumentClickForSubmenu);
483
- window.addEventListener('resize', handleWindowResizeForSubmenu);
484
- window.addEventListener('scroll', handleWindowScrollForSubmenu);
493
+ window.addEventListener('resize', handleWindowResizeForSubmenu, { passive: true });
494
+ window.addEventListener('scroll', handleWindowScrollForSubmenu, { passive: true });
485
495
 
486
- // Make visible
487
- setTimeout(() => {
496
+ // Make visible with animation
497
+ requestAnimationFrame(() => {
488
498
  submenuElement.classList.add(`${component.getClass('menu--visible')}`);
489
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
+
490
510
  // If opened via keyboard, focus the first item in the submenu
491
511
  if (viaKeyboard && submenuItems.length > 0) {
492
512
  setTimeout(() => {
493
513
  submenuItems[0].focus();
494
514
  }, 50);
495
515
  }
496
- }, 10);
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
+ }
497
577
  };
498
578
 
499
579
  /**
@@ -507,36 +587,94 @@ export const withController = (config: MenuConfig) => component => {
507
587
  };
508
588
 
509
589
  /**
510
- * Closes any open submenu
590
+ * Closes submenus at or deeper than the specified level
591
+ * @param level - The level to start closing from
511
592
  */
512
- const closeSubmenu = (): void => {
513
- // Clear timers
593
+ const closeSubmenuAtLevel = (level: number): void => {
594
+ // Clear any hover intent or submenu timers
514
595
  clearHoverIntent();
515
596
  clearSubmenuTimer();
516
597
 
517
- if (!state.activeSubmenu) return;
598
+ // Find submenus at or deeper than the specified level
599
+ const submenusCopy = [...state.activeSubmenus];
600
+ const submenuIndicesToRemove = [];
518
601
 
519
- // Remove expanded state from all items
520
- if (state.activeSubmenuItem) {
521
- state.activeSubmenuItem.setAttribute('aria-expanded', 'false');
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
+ }
522
625
  }
523
626
 
524
- // Remove submenu element with animation
525
- state.activeSubmenu.classList.remove(`${component.getClass('menu--visible')}`);
627
+ // Remove the closed submenus from state
628
+ submenuIndicesToRemove.forEach(index => {
629
+ state.activeSubmenus.splice(index, 1);
630
+ });
526
631
 
527
- // Store reference for cleanup
528
- const submenuToRemove = state.activeSubmenu;
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();
529
652
 
530
- // Remove after animation
531
- setTimeout(() => {
532
- if (submenuToRemove && submenuToRemove.parentNode) {
533
- submenuToRemove.parentNode.removeChild(submenuToRemove);
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');
534
660
  }
535
- }, 200);
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
+ });
536
672
 
537
673
  // Clear state
538
674
  state.activeSubmenu = null;
539
675
  state.activeSubmenuItem = null;
676
+ state.activeSubmenus = [];
677
+ state.submenuLevel = 0;
540
678
 
541
679
  // Remove document events
542
680
  document.removeEventListener('click', handleDocumentClickForSubmenu);
@@ -567,46 +705,75 @@ export const withController = (config: MenuConfig) => component => {
567
705
  * Handles window resize for submenu
568
706
  */
569
707
  const handleWindowResizeForSubmenu = (): void => {
570
- // Reposition or close submenu on resize
571
- closeSubmenu();
708
+ // Reposition open submenu on resize
709
+ if (state.activeSubmenu && state.activeSubmenuItem) {
710
+ positioner.positionSubmenu(state.activeSubmenu, state.activeSubmenuItem, state.submenuLevel);
711
+ }
572
712
  };
573
713
 
574
714
  /**
575
715
  * Handles window scroll for submenu
716
+ * Repositions the submenu to stay attached to its parent during scrolling
576
717
  */
577
718
  const handleWindowScrollForSubmenu = (): void => {
578
- // Reposition or close submenu on scroll
579
- closeSubmenu();
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
+ });
580
726
  };
581
727
 
582
728
  /**
583
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
584
732
  */
585
- /**
586
- * Opens the menu
587
- */
588
- const openMenu = (event?: Event): void => {
733
+ const openMenu = (event?: Event, interactionType: 'mouse' | 'keyboard' = 'mouse'): void => {
589
734
  if (state.visible) return;
590
735
 
591
736
  // Update state
592
737
  state.visible = true;
593
738
 
594
- // Add the menu to the DOM if it's not already there
739
+ // Step 1: Add the menu to the DOM if it's not already there with initial hidden state
595
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
596
748
  document.body.appendChild(component.element);
597
749
  }
598
750
 
599
- // Set attributes
600
- component.element.setAttribute('aria-hidden', 'false');
601
- component.element.classList.add(`${component.getClass('menu--visible')}`);
602
-
603
- // Position
604
- positionMenu();
751
+ // Step 2: Position the menu (will be invisible)
752
+ const anchorElement = getAnchorElement();
753
+ if (anchorElement) {
754
+ positioner.positionMenu(anchorElement);
755
+ }
605
756
 
606
- // Focus first item
757
+ // Step 3: Use a small delay to ensure DOM operations are complete
607
758
  setTimeout(() => {
608
- focusFirstItem();
609
- }, 100);
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
610
777
 
611
778
  // Add document events
612
779
  if (config.closeOnClickOutside) {
@@ -615,8 +782,8 @@ export const withController = (config: MenuConfig) => component => {
615
782
  if (config.closeOnEscape) {
616
783
  document.addEventListener('keydown', handleDocumentKeydown);
617
784
  }
618
- window.addEventListener('resize', handleWindowResize);
619
- window.addEventListener('scroll', handleWindowScroll);
785
+ window.addEventListener('resize', handleWindowResize, { passive: true });
786
+ window.addEventListener('scroll', handleWindowScroll, { passive: true });
620
787
 
621
788
  // Trigger event
622
789
  eventHelpers.triggerEvent('open', {}, event);
@@ -699,33 +866,35 @@ export const withController = (config: MenuConfig) => component => {
699
866
  */
700
867
  const handleWindowResize = (): void => {
701
868
  if (state.visible) {
702
- positionMenu();
869
+ const anchorElement = getAnchorElement();
870
+ if (anchorElement) {
871
+ positioner.positionMenu(anchorElement);
872
+ }
703
873
  }
704
874
  };
705
875
 
706
876
  /**
707
877
  * Handles window scroll
878
+ * Repositions the menu to stay attached to its anchor during scrolling
708
879
  */
709
880
  const handleWindowScroll = (): void => {
710
881
  if (state.visible) {
711
- positionMenu();
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
+ });
712
895
  }
713
896
  };
714
897
 
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
898
  /**
730
899
  * Handles keydown events on the menu or submenu
731
900
  */
@@ -828,7 +997,16 @@ export const withController = (config: MenuConfig) => component => {
828
997
  if (state.activeSubmenuItem) {
829
998
  // Store the reference to the parent item before closing the submenu
830
999
  const parentItem = state.activeSubmenuItem;
831
- closeSubmenu();
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
+
832
1010
  // Focus the parent item after closing
833
1011
  parentItem.focus();
834
1012
  } else {
@@ -844,7 +1022,16 @@ export const withController = (config: MenuConfig) => component => {
844
1022
  if (state.activeSubmenuItem) {
845
1023
  // Store the reference to the parent item before closing the submenu
846
1024
  const parentItem = state.activeSubmenuItem;
847
- closeSubmenu();
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
+
848
1035
  // Focus the parent item after closing
849
1036
  parentItem.focus();
850
1037
  } else {
@@ -877,7 +1064,10 @@ export const withController = (config: MenuConfig) => component => {
877
1064
 
878
1065
  // Position if visible
879
1066
  if (state.visible) {
880
- positionMenu();
1067
+ const anchorElement = getAnchorElement();
1068
+ if (anchorElement) {
1069
+ positioner.positionMenu(anchorElement);
1070
+ }
881
1071
 
882
1072
  // Show immediately
883
1073
  component.element.classList.add(`${component.getClass('menu--visible')}`);
@@ -917,8 +1107,12 @@ export const withController = (config: MenuConfig) => component => {
917
1107
  window.removeEventListener('scroll', handleWindowScrollForSubmenu);
918
1108
 
919
1109
  // Clean up submenu element
920
- if (state.activeSubmenu && state.activeSubmenu.parentNode) {
921
- state.activeSubmenu.parentNode.removeChild(state.activeSubmenu);
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
+ });
922
1116
  }
923
1117
 
924
1118
  originalDestroy();
@@ -954,15 +1148,18 @@ export const withController = (config: MenuConfig) => component => {
954
1148
 
955
1149
  getItems: () => state.items,
956
1150
 
957
- setPlacement: (placement) => {
958
- state.placement = placement;
1151
+ setPosition: (position) => {
1152
+ state.position = position;
959
1153
  if (state.visible) {
960
- positionMenu();
1154
+ const anchorElement = getAnchorElement();
1155
+ if (anchorElement) {
1156
+ positioner.positionMenu(anchorElement);
1157
+ }
961
1158
  }
962
1159
  return component;
963
1160
  },
964
1161
 
965
- getPlacement: () => state.placement
1162
+ getPosition: () => state.position
966
1163
  }
967
1164
  };
968
1165
  };