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