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.
- package/package.json +1 -1
- package/src/components/button/api.ts +16 -0
- package/src/components/button/types.ts +9 -0
- package/src/components/menu/api.ts +15 -13
- package/src/components/menu/config.ts +5 -5
- package/src/components/menu/features/anchor.ts +99 -15
- package/src/components/menu/features/controller.ts +418 -221
- package/src/components/menu/features/index.ts +2 -1
- package/src/components/menu/features/position.ts +353 -0
- package/src/components/menu/index.ts +5 -5
- package/src/components/menu/menu.ts +18 -60
- package/src/components/menu/types.ts +17 -16
- package/src/components/select/api.ts +78 -0
- package/src/components/select/config.ts +76 -0
- package/src/components/select/features.ts +317 -0
- package/src/components/select/index.ts +38 -0
- package/src/components/select/select.ts +73 -0
- package/src/components/select/types.ts +355 -0
- package/src/components/textfield/api.ts +78 -6
- package/src/components/textfield/features/index.ts +17 -0
- package/src/components/textfield/features/leading-icon.ts +127 -0
- package/src/components/textfield/features/placement.ts +149 -0
- package/src/components/textfield/features/prefix-text.ts +107 -0
- package/src/components/textfield/features/suffix-text.ts +100 -0
- package/src/components/textfield/features/supporting-text.ts +113 -0
- package/src/components/textfield/features/trailing-icon.ts +108 -0
- package/src/components/textfield/textfield.ts +51 -15
- package/src/components/textfield/types.ts +70 -0
- package/src/core/collection/adapters/base.ts +62 -0
- package/src/core/collection/collection.ts +300 -0
- package/src/core/collection/index.ts +57 -0
- package/src/core/collection/list-manager.ts +333 -0
- package/src/index.ts +4 -1
- package/src/styles/abstract/_variables.scss +18 -0
- package/src/styles/components/_button.scss +21 -5
- package/src/styles/components/{_chip.scss → _chips.scss} +118 -4
- package/src/styles/components/_menu.scss +103 -24
- package/src/styles/components/_select.scss +265 -0
- package/src/styles/components/_textfield.scss +233 -42
- package/src/styles/main.scss +2 -1
- package/src/components/textfield/features.ts +0 -322
- package/src/core/collection/adapters/base.js +0 -26
- package/src/core/collection/collection.js +0 -259
- package/src/core/collection/list-manager.js +0 -157
- /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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
415
|
-
|
|
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
|
-
//
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
//
|
|
476
|
+
// Update state with active submenu
|
|
478
477
|
state.activeSubmenu = submenuElement;
|
|
479
478
|
state.activeSubmenuItem = itemElement;
|
|
480
479
|
|
|
481
|
-
// Add
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
590
|
+
* Closes submenus at or deeper than the specified level
|
|
591
|
+
* @param level - The level to start closing from
|
|
511
592
|
*/
|
|
512
|
-
const
|
|
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
|
-
|
|
598
|
+
// Find submenus at or deeper than the specified level
|
|
599
|
+
const submenusCopy = [...state.activeSubmenus];
|
|
600
|
+
const submenuIndicesToRemove = [];
|
|
518
601
|
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
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
|
|
525
|
-
|
|
627
|
+
// Remove the closed submenus from state
|
|
628
|
+
submenuIndicesToRemove.forEach(index => {
|
|
629
|
+
state.activeSubmenus.splice(index, 1);
|
|
630
|
+
});
|
|
526
631
|
|
|
527
|
-
//
|
|
528
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
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
|
|
571
|
-
|
|
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
|
-
//
|
|
579
|
-
|
|
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
|
-
//
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
//
|
|
757
|
+
// Step 3: Use a small delay to ensure DOM operations are complete
|
|
607
758
|
setTimeout(() => {
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
921
|
-
state.
|
|
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
|
-
|
|
958
|
-
state.
|
|
1151
|
+
setPosition: (position) => {
|
|
1152
|
+
state.position = position;
|
|
959
1153
|
if (state.visible) {
|
|
960
|
-
|
|
1154
|
+
const anchorElement = getAnchorElement();
|
|
1155
|
+
if (anchorElement) {
|
|
1156
|
+
positioner.positionMenu(anchorElement);
|
|
1157
|
+
}
|
|
961
1158
|
}
|
|
962
1159
|
return component;
|
|
963
1160
|
},
|
|
964
1161
|
|
|
965
|
-
|
|
1162
|
+
getPosition: () => state.position
|
|
966
1163
|
}
|
|
967
1164
|
};
|
|
968
1165
|
};
|