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