mtrl 0.3.7 → 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/menu/api.ts +46 -9
- package/src/components/menu/config.ts +6 -4
- package/src/components/menu/features/anchor.ts +155 -4
- package/src/components/menu/features/controller.ts +311 -55
- package/src/components/menu/features/index.ts +11 -3
- package/src/components/menu/features/position.ts +1 -1
- package/src/components/menu/menu.ts +4 -2
- package/src/components/menu/types.ts +13 -0
- package/src/components/select/features.ts +58 -44
- package/src/index.ts +1 -45
- package/src/styles/components/_menu.scss +8 -14
- package/src/styles/components/_select.scss +11 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mtrl",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "A functional TypeScript/JavaScript component library with composable architecture based on Material Design 3",
|
|
5
5
|
"author": "floor",
|
|
6
6
|
"license": "MIT License",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"ui",
|
|
11
11
|
"user interface",
|
|
12
12
|
"typescript",
|
|
13
|
-
"functional",
|
|
13
|
+
"functional",
|
|
14
14
|
"composable",
|
|
15
15
|
"material design 3",
|
|
16
16
|
"md3",
|
|
@@ -9,14 +9,16 @@ import { MenuComponent, MenuContent, MenuPosition, MenuEvent, MenuSelectEvent }
|
|
|
9
9
|
*/
|
|
10
10
|
interface ApiOptions {
|
|
11
11
|
menu: {
|
|
12
|
-
open: (event?: Event) => any;
|
|
13
|
-
close: (event?: Event) => any;
|
|
14
|
-
toggle: (event?: Event) => any;
|
|
12
|
+
open: (event?: Event, interactionType?: 'mouse' | 'keyboard') => any;
|
|
13
|
+
close: (event?: Event, restoreFocus?: boolean, skipAnimation?: boolean) => any;
|
|
14
|
+
toggle: (event?: Event, interactionType?: 'mouse' | 'keyboard') => any;
|
|
15
15
|
isOpen: () => boolean;
|
|
16
16
|
setItems: (items: MenuContent[]) => any;
|
|
17
17
|
getItems: () => MenuContent[];
|
|
18
18
|
setPosition: (position: MenuPosition) => any;
|
|
19
19
|
getPosition: () => MenuPosition;
|
|
20
|
+
setSelected: (itemId: string) => any;
|
|
21
|
+
getSelected: () => string | null;
|
|
20
22
|
};
|
|
21
23
|
anchor: {
|
|
22
24
|
setAnchor: (anchor: HTMLElement | string) => any;
|
|
@@ -53,7 +55,7 @@ interface ComponentWithElements {
|
|
|
53
55
|
* @category Components
|
|
54
56
|
* @internal This is an internal utility for the Menu component
|
|
55
57
|
*/
|
|
56
|
-
|
|
58
|
+
const withAPI = ({ menu, anchor, events, lifecycle }: ApiOptions) =>
|
|
57
59
|
(component: ComponentWithElements): MenuComponent => ({
|
|
58
60
|
...component as any,
|
|
59
61
|
element: component.element,
|
|
@@ -66,6 +68,13 @@ export const withAPI = ({ menu, anchor, events, lifecycle }: ApiOptions) =>
|
|
|
66
68
|
* @returns Menu component for chaining
|
|
67
69
|
*/
|
|
68
70
|
open(event?: Event, interactionType: 'mouse' | 'keyboard' = 'mouse') {
|
|
71
|
+
// Determine interaction type from event if not explicitly provided
|
|
72
|
+
if (event && !interactionType) {
|
|
73
|
+
if (event instanceof KeyboardEvent) {
|
|
74
|
+
interactionType = 'keyboard';
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
69
78
|
menu.open(event, interactionType);
|
|
70
79
|
return this;
|
|
71
80
|
},
|
|
@@ -75,18 +84,28 @@ export const withAPI = ({ menu, anchor, events, lifecycle }: ApiOptions) =>
|
|
|
75
84
|
* @param event - Optional event that triggered the close
|
|
76
85
|
* @returns Menu component for chaining
|
|
77
86
|
*/
|
|
78
|
-
close(event?: Event) {
|
|
79
|
-
menu.close(event);
|
|
87
|
+
close(event?: Event, restoreFocus: boolean = true, skipAnimation: boolean = false) {
|
|
88
|
+
menu.close(event, restoreFocus, skipAnimation);
|
|
80
89
|
return this;
|
|
81
90
|
},
|
|
82
91
|
|
|
83
92
|
/**
|
|
84
93
|
* Toggles the menu's open state
|
|
85
94
|
* @param event - Optional event that triggered the toggle
|
|
95
|
+
* @param interactionType - The type of interaction that triggered the toggle
|
|
86
96
|
* @returns Menu component for chaining
|
|
87
97
|
*/
|
|
88
|
-
toggle(event?: Event) {
|
|
89
|
-
|
|
98
|
+
toggle(event?: Event, interactionType?: 'mouse' | 'keyboard') {
|
|
99
|
+
// Determine interaction type from event if not explicitly provided
|
|
100
|
+
if (event && !interactionType) {
|
|
101
|
+
if (event instanceof KeyboardEvent) {
|
|
102
|
+
interactionType = 'keyboard';
|
|
103
|
+
} else if (event instanceof MouseEvent) {
|
|
104
|
+
interactionType = 'mouse';
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
menu.toggle(event, interactionType);
|
|
90
109
|
return this;
|
|
91
110
|
},
|
|
92
111
|
|
|
@@ -152,6 +171,24 @@ export const withAPI = ({ menu, anchor, events, lifecycle }: ApiOptions) =>
|
|
|
152
171
|
return menu.getPosition();
|
|
153
172
|
},
|
|
154
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Sets the selected menu item
|
|
176
|
+
* @param itemId - ID of the menu item to mark as selected
|
|
177
|
+
* @returns Menu component for chaining
|
|
178
|
+
*/
|
|
179
|
+
setSelected(itemId: string) {
|
|
180
|
+
menu.setSelected(itemId);
|
|
181
|
+
return this;
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Gets the currently selected menu item's ID
|
|
186
|
+
* @returns ID of the selected menu item or null if none is selected
|
|
187
|
+
*/
|
|
188
|
+
getSelected() {
|
|
189
|
+
return menu.getSelected();
|
|
190
|
+
},
|
|
191
|
+
|
|
155
192
|
/**
|
|
156
193
|
* Adds an event listener to the menu
|
|
157
194
|
* @param event - Event name ('open', 'close', 'select')
|
|
@@ -190,4 +227,4 @@ export const withAPI = ({ menu, anchor, events, lifecycle }: ApiOptions) =>
|
|
|
190
227
|
}
|
|
191
228
|
});
|
|
192
229
|
|
|
193
|
-
export
|
|
230
|
+
export { withAPI };
|
|
@@ -100,14 +100,16 @@ export const getElementConfig = (config: MenuConfig) => {
|
|
|
100
100
|
*/
|
|
101
101
|
export const getApiConfig = (component) => ({
|
|
102
102
|
menu: {
|
|
103
|
-
open: () => component.menu?.open(),
|
|
104
|
-
close: () => component.menu?.close(),
|
|
105
|
-
toggle: () => component.menu?.toggle(),
|
|
103
|
+
open: (event, interactionType) => component.menu?.open(event, interactionType),
|
|
104
|
+
close: (event, restoreFocus, skipAnimation) => component.menu?.close(event, restoreFocus, skipAnimation),
|
|
105
|
+
toggle: (event, interactionType) => component.menu?.toggle(event, interactionType),
|
|
106
106
|
isOpen: () => component.menu?.isOpen() || false,
|
|
107
107
|
setItems: (items) => component.menu?.setItems(items),
|
|
108
108
|
getItems: () => component.menu?.getItems() || [],
|
|
109
109
|
setPosition: (position) => component.menu?.setPosition(position),
|
|
110
|
-
getPosition: () => component.menu?.getPosition()
|
|
110
|
+
getPosition: () => component.menu?.getPosition(),
|
|
111
|
+
setSelected: (itemId) => component.menu?.setSelected(itemId),
|
|
112
|
+
getSelected: () => component.menu?.getSelected()
|
|
111
113
|
},
|
|
112
114
|
anchor: {
|
|
113
115
|
setAnchor: (anchor) => component.anchor?.setAnchor(anchor),
|
|
@@ -9,12 +9,26 @@ import { MenuConfig } from '../types';
|
|
|
9
9
|
* @param config - Menu configuration
|
|
10
10
|
* @returns Component enhancer with anchor management functionality
|
|
11
11
|
*/
|
|
12
|
-
|
|
12
|
+
const withAnchor = (config: MenuConfig) => component => {
|
|
13
13
|
if (!component.element) {
|
|
14
14
|
console.warn('Cannot initialize menu anchor: missing element');
|
|
15
15
|
return component;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Track keyboard navigation state
|
|
19
|
+
let isTabNavigation = false;
|
|
20
|
+
|
|
21
|
+
// Add an event listener to detect Tab key navigation
|
|
22
|
+
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
23
|
+
// Set flag when Tab key is pressed
|
|
24
|
+
isTabNavigation = e.key === 'Tab';
|
|
25
|
+
|
|
26
|
+
// Reset flag after a short delay
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
isTabNavigation = false;
|
|
29
|
+
}, 100);
|
|
30
|
+
});
|
|
31
|
+
|
|
18
32
|
// Track anchor state
|
|
19
33
|
const state = {
|
|
20
34
|
anchorElement: null as HTMLElement,
|
|
@@ -95,6 +109,12 @@ export const withAnchor = (config: MenuConfig) => component => {
|
|
|
95
109
|
// Add click handler
|
|
96
110
|
anchorElement.addEventListener('click', handleAnchorClick);
|
|
97
111
|
|
|
112
|
+
// Add keyboard handlers
|
|
113
|
+
anchorElement.addEventListener('keydown', handleAnchorKeydown);
|
|
114
|
+
|
|
115
|
+
// Add blur/focusout handler to close menu when anchor loses focus
|
|
116
|
+
anchorElement.addEventListener('blur', handleAnchorBlur);
|
|
117
|
+
|
|
98
118
|
// Add ARIA attributes
|
|
99
119
|
anchorElement.setAttribute('aria-haspopup', 'true');
|
|
100
120
|
anchorElement.setAttribute('aria-expanded', 'false');
|
|
@@ -142,17 +162,129 @@ export const withAnchor = (config: MenuConfig) => component => {
|
|
|
142
162
|
const handleAnchorClick = (e: MouseEvent): void => {
|
|
143
163
|
e.preventDefault();
|
|
144
164
|
|
|
145
|
-
// Toggle menu visibility
|
|
165
|
+
// Toggle menu visibility with mouse interaction type
|
|
146
166
|
if (component.menu) {
|
|
147
167
|
const isOpen = component.menu.isOpen();
|
|
148
168
|
|
|
149
169
|
if (isOpen) {
|
|
150
170
|
component.menu.close(e);
|
|
151
171
|
} else {
|
|
152
|
-
component.menu.open(e);
|
|
172
|
+
component.menu.open(e, 'mouse');
|
|
153
173
|
}
|
|
154
174
|
}
|
|
155
175
|
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Handles keyboard events on the anchor element
|
|
179
|
+
*/
|
|
180
|
+
const handleAnchorKeydown = (e: KeyboardEvent): void => {
|
|
181
|
+
// Only handle events if we have a menu controller
|
|
182
|
+
if (!component.menu) return;
|
|
183
|
+
|
|
184
|
+
// Determine if menu is currently open
|
|
185
|
+
const isOpen = component.menu.isOpen();
|
|
186
|
+
|
|
187
|
+
switch (e.key) {
|
|
188
|
+
case 'Enter':
|
|
189
|
+
case ' ': // Space
|
|
190
|
+
case 'ArrowDown':
|
|
191
|
+
case 'Down':
|
|
192
|
+
// Prevent default browser behavior
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
|
|
195
|
+
// Open menu if closed, with keyboard interaction type
|
|
196
|
+
if (!isOpen) {
|
|
197
|
+
component.menu.open(e, 'keyboard');
|
|
198
|
+
}
|
|
199
|
+
break;
|
|
200
|
+
|
|
201
|
+
case 'Escape':
|
|
202
|
+
// Close the menu if it's open
|
|
203
|
+
if (isOpen) {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
component.menu.close(e);
|
|
206
|
+
}
|
|
207
|
+
break;
|
|
208
|
+
|
|
209
|
+
case 'ArrowUp':
|
|
210
|
+
case 'Up':
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
|
|
213
|
+
// Special case: open menu with focus on last item
|
|
214
|
+
if (!isOpen) {
|
|
215
|
+
component.menu.open(e, 'keyboard');
|
|
216
|
+
|
|
217
|
+
// Wait for menu to open and grab the last item
|
|
218
|
+
setTimeout(() => {
|
|
219
|
+
const items = component.element.querySelectorAll(
|
|
220
|
+
`.${component.getClass('menu-item')}:not(.${component.getClass('menu-item--disabled')})`
|
|
221
|
+
) as NodeListOf<HTMLElement>;
|
|
222
|
+
|
|
223
|
+
if (items.length > 0) {
|
|
224
|
+
// Reset tabindex for all items
|
|
225
|
+
items.forEach(item => item.setAttribute('tabindex', '-1'));
|
|
226
|
+
|
|
227
|
+
// Set the last item as active
|
|
228
|
+
const lastItem = items[items.length - 1];
|
|
229
|
+
lastItem.setAttribute('tabindex', '0');
|
|
230
|
+
lastItem.focus();
|
|
231
|
+
}
|
|
232
|
+
}, 100);
|
|
233
|
+
}
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Handles anchor blur/focusout events
|
|
240
|
+
*/
|
|
241
|
+
const handleAnchorBlur = (e: FocusEvent): void => {
|
|
242
|
+
// Only handle events if we have a menu controller and menu is open
|
|
243
|
+
if (!component.menu || !component.menu.isOpen()) return;
|
|
244
|
+
|
|
245
|
+
// Get the related target (element receiving focus)
|
|
246
|
+
const relatedTarget = e.relatedTarget as HTMLElement;
|
|
247
|
+
|
|
248
|
+
// If this is tab navigation, always close the menu regardless of next focus target
|
|
249
|
+
if (isTabNavigation) {
|
|
250
|
+
setTimeout(() => {
|
|
251
|
+
// Verify menu is still open (may have been closed in the meantime)
|
|
252
|
+
if (component.menu && component.menu.isOpen()) {
|
|
253
|
+
// Close the menu but don't restore focus
|
|
254
|
+
component.menu.close(e, false);
|
|
255
|
+
}
|
|
256
|
+
}, 10);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// For non-tab navigation (like mouse clicks):
|
|
261
|
+
// Don't close if focus is moving to any of these:
|
|
262
|
+
// 1. To the menu itself
|
|
263
|
+
// 2. To a child of the menu
|
|
264
|
+
// 3. To another menu button/anchor
|
|
265
|
+
if (relatedTarget) {
|
|
266
|
+
// Check if focus moved to menu or its children
|
|
267
|
+
if (component.element.contains(relatedTarget)) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Check if focus moved to another menu button/anchor (has aria-haspopup)
|
|
272
|
+
if (relatedTarget.getAttribute('aria-haspopup') === 'true' ||
|
|
273
|
+
relatedTarget.closest('[aria-haspopup="true"]')) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Wait a brief moment to ensure we're not in the middle of another operation
|
|
279
|
+
// This helps prevent conflicts with click handlers
|
|
280
|
+
setTimeout(() => {
|
|
281
|
+
// Verify menu is still open (may have been closed in the meantime)
|
|
282
|
+
if (component.menu && component.menu.isOpen()) {
|
|
283
|
+
// Close the menu but don't restore focus since focus has moved elsewhere
|
|
284
|
+
component.menu.close(e, false);
|
|
285
|
+
}
|
|
286
|
+
}, 50);
|
|
287
|
+
};
|
|
156
288
|
|
|
157
289
|
/**
|
|
158
290
|
* Removes event listeners from anchor
|
|
@@ -160,6 +292,8 @@ export const withAnchor = (config: MenuConfig) => component => {
|
|
|
160
292
|
const cleanup = (): void => {
|
|
161
293
|
if (state.anchorElement) {
|
|
162
294
|
state.anchorElement.removeEventListener('click', handleAnchorClick);
|
|
295
|
+
state.anchorElement.removeEventListener('keydown', handleAnchorKeydown);
|
|
296
|
+
state.anchorElement.removeEventListener('blur', handleAnchorBlur);
|
|
163
297
|
state.anchorElement.removeAttribute('aria-haspopup');
|
|
164
298
|
state.anchorElement.removeAttribute('aria-expanded');
|
|
165
299
|
state.anchorElement.removeAttribute('aria-controls');
|
|
@@ -195,10 +329,27 @@ export const withAnchor = (config: MenuConfig) => component => {
|
|
|
195
329
|
}
|
|
196
330
|
});
|
|
197
331
|
|
|
198
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Update the event listener for menu close event to ensure focus restoration
|
|
334
|
+
*/
|
|
335
|
+
component.on('close', (event) => {
|
|
199
336
|
if (state.anchorElement) {
|
|
337
|
+
// Always update ARIA attributes
|
|
200
338
|
state.anchorElement.setAttribute('aria-expanded', 'false');
|
|
201
339
|
setAnchorActive(false);
|
|
340
|
+
|
|
341
|
+
// Only handle focus restoration for Escape key cases
|
|
342
|
+
// Do NOT restore focus if:
|
|
343
|
+
// 1. It's a tab navigation event, OR
|
|
344
|
+
// 2. There's a next focus element waiting to be focused
|
|
345
|
+
const isTabNavigation = event.isTabNavigation || window._menuNextFocusElement !== null;
|
|
346
|
+
|
|
347
|
+
if (event.originalEvent?.key === 'Escape' && !isTabNavigation) {
|
|
348
|
+
// Only in this case, restore focus to anchor
|
|
349
|
+
requestAnimationFrame(() => {
|
|
350
|
+
state.anchorElement.focus();
|
|
351
|
+
});
|
|
352
|
+
}
|
|
202
353
|
}
|
|
203
354
|
});
|
|
204
355
|
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
import { MenuConfig, MenuContent, MenuItem, MenuDivider, MenuEvent, MenuSelectEvent } from '../types';
|
|
4
4
|
import { createPositioner } from './position';
|
|
5
5
|
|
|
6
|
+
let ignoreNextDocumentClick = false;
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Adds controller functionality to the menu component
|
|
8
10
|
* Manages state, rendering, positioning, and event handling
|
|
@@ -10,7 +12,7 @@ import { createPositioner } from './position';
|
|
|
10
12
|
* @param config - Menu configuration
|
|
11
13
|
* @returns Component enhancer with menu controller functionality
|
|
12
14
|
*/
|
|
13
|
-
|
|
15
|
+
const withController = (config: MenuConfig) => component => {
|
|
14
16
|
if (!component.element) {
|
|
15
17
|
console.warn('Cannot initialize menu controller: missing element');
|
|
16
18
|
return component;
|
|
@@ -21,6 +23,7 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
21
23
|
visible: config.visible || false,
|
|
22
24
|
items: config.items || [],
|
|
23
25
|
position: config.position,
|
|
26
|
+
selectedItemId: null as string | null,
|
|
24
27
|
activeSubmenu: null as HTMLElement,
|
|
25
28
|
activeSubmenuItem: null as HTMLElement,
|
|
26
29
|
activeItemIndex: -1,
|
|
@@ -36,7 +39,8 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
36
39
|
timer: null,
|
|
37
40
|
activeItem: null
|
|
38
41
|
},
|
|
39
|
-
component
|
|
42
|
+
component,
|
|
43
|
+
keyboardNavActive: false // Track if keyboard navigation is active
|
|
40
44
|
};
|
|
41
45
|
|
|
42
46
|
// Create positioner
|
|
@@ -108,6 +112,14 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
108
112
|
itemElement.setAttribute('aria-disabled', 'false');
|
|
109
113
|
}
|
|
110
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
|
+
|
|
111
123
|
if (item.hasSubmenu) {
|
|
112
124
|
itemElement.classList.add(`${itemClass}--submenu`);
|
|
113
125
|
itemElement.setAttribute('aria-haspopup', 'true');
|
|
@@ -150,6 +162,7 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
150
162
|
// Focus and blur events for proper focus styling
|
|
151
163
|
itemElement.addEventListener('focus', () => {
|
|
152
164
|
state.activeItemIndex = index;
|
|
165
|
+
state.keyboardNavActive = true;
|
|
153
166
|
});
|
|
154
167
|
|
|
155
168
|
// Additional keyboard event handler for accessibility
|
|
@@ -236,10 +249,15 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
236
249
|
) as HTMLElement[];
|
|
237
250
|
|
|
238
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
|
+
|
|
239
257
|
// Focus the first item for keyboard navigation
|
|
240
|
-
items[0].setAttribute('tabindex', '0');
|
|
241
258
|
items[0].focus();
|
|
242
259
|
state.activeItemIndex = 0;
|
|
260
|
+
state.keyboardNavActive = true;
|
|
243
261
|
} else {
|
|
244
262
|
// If no items, focus the menu itself
|
|
245
263
|
component.element.setAttribute('tabindex', '0');
|
|
@@ -248,6 +266,18 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
248
266
|
} else {
|
|
249
267
|
// For mouse interaction, make the menu focusable but don't auto-focus
|
|
250
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
|
+
}
|
|
251
281
|
}
|
|
252
282
|
};
|
|
253
283
|
|
|
@@ -321,6 +351,9 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
321
351
|
const handleSubmenuHover = (item: MenuItem, index: number, itemElement: HTMLElement): void => {
|
|
322
352
|
if (!config.openSubmenuOnHover || !item.hasSubmenu) return;
|
|
323
353
|
|
|
354
|
+
// If keyboard navigation is active, don't open submenu on hover
|
|
355
|
+
if (state.keyboardNavActive) return;
|
|
356
|
+
|
|
324
357
|
// Clear any existing timers
|
|
325
358
|
clearHoverIntent();
|
|
326
359
|
clearSubmenuTimer();
|
|
@@ -343,6 +376,9 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
343
376
|
* Handles mouse leave from submenu
|
|
344
377
|
*/
|
|
345
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
|
+
|
|
346
382
|
// Clear hover intent
|
|
347
383
|
clearHoverIntent();
|
|
348
384
|
|
|
@@ -374,6 +410,11 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
374
410
|
const openSubmenu = (item: MenuItem, index: number, itemElement: HTMLElement, viaKeyboard = false): void => {
|
|
375
411
|
if (!item.submenu || !item.hasSubmenu) return;
|
|
376
412
|
|
|
413
|
+
// If opened via keyboard, update the keyboard navigation state
|
|
414
|
+
if (viaKeyboard) {
|
|
415
|
+
state.keyboardNavActive = true;
|
|
416
|
+
}
|
|
417
|
+
|
|
377
418
|
// Get current level of the submenu we're opening
|
|
378
419
|
const currentLevel = itemElement.closest(`.${component.getClass('menu--submenu')}`)
|
|
379
420
|
? parseInt(itemElement.closest(`.${component.getClass('menu--submenu')}`).getAttribute('data-level') || '0', 10) + 1
|
|
@@ -435,12 +476,16 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
435
476
|
|
|
436
477
|
// Add mouseenter event to prevent closing
|
|
437
478
|
submenuElement.addEventListener('mouseenter', () => {
|
|
438
|
-
|
|
479
|
+
if (!state.keyboardNavActive) {
|
|
480
|
+
clearSubmenuTimer();
|
|
481
|
+
}
|
|
439
482
|
});
|
|
440
483
|
|
|
441
484
|
// Add mouseleave event to handle closing
|
|
442
485
|
submenuElement.addEventListener('mouseleave', (e) => {
|
|
443
|
-
|
|
486
|
+
if (!state.keyboardNavActive) {
|
|
487
|
+
handleSubmenuLeave(e);
|
|
488
|
+
}
|
|
444
489
|
});
|
|
445
490
|
|
|
446
491
|
// Setup submenu event handlers for nested submenus
|
|
@@ -509,6 +554,14 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
509
554
|
|
|
510
555
|
// If opened via keyboard, focus the first item in the submenu
|
|
511
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
|
|
512
565
|
setTimeout(() => {
|
|
513
566
|
submenuItems[0].focus();
|
|
514
567
|
}, 50);
|
|
@@ -520,7 +573,7 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
520
573
|
* Handles hover on a nested submenu item
|
|
521
574
|
*/
|
|
522
575
|
const handleNestedSubmenuHover = (item: MenuItem, index: number, itemElement: HTMLElement): void => {
|
|
523
|
-
if (!config.openSubmenuOnHover || !item.hasSubmenu) return;
|
|
576
|
+
if (!config.openSubmenuOnHover || !item.hasSubmenu || state.keyboardNavActive) return;
|
|
524
577
|
|
|
525
578
|
// Clear any existing timers
|
|
526
579
|
clearHoverIntent();
|
|
@@ -733,9 +786,15 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
733
786
|
const openMenu = (event?: Event, interactionType: 'mouse' | 'keyboard' = 'mouse'): void => {
|
|
734
787
|
if (state.visible) return;
|
|
735
788
|
|
|
789
|
+
// Set keyboard navigation state based on interaction type
|
|
790
|
+
state.keyboardNavActive = interactionType === 'keyboard';
|
|
791
|
+
|
|
736
792
|
// Update state
|
|
737
793
|
state.visible = true;
|
|
738
794
|
|
|
795
|
+
// First, remove any existing document click listener
|
|
796
|
+
document.removeEventListener('click', handleDocumentClick);
|
|
797
|
+
|
|
739
798
|
// Step 1: Add the menu to the DOM if it's not already there with initial hidden state
|
|
740
799
|
if (!component.element.parentNode) {
|
|
741
800
|
// Apply explicit initial styling to ensure it doesn't flash
|
|
@@ -773,28 +832,45 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
773
832
|
setTimeout(() => {
|
|
774
833
|
handleFocus(interactionType);
|
|
775
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);
|
|
776
850
|
}, 20); // Short delay for browser to process
|
|
777
851
|
|
|
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
852
|
// Trigger event
|
|
789
853
|
eventHelpers.triggerEvent('open', {}, event);
|
|
790
854
|
};
|
|
791
855
|
|
|
792
856
|
/**
|
|
793
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)
|
|
794
861
|
*/
|
|
795
|
-
const closeMenu = (event?: Event): void => {
|
|
862
|
+
const closeMenu = (event?: Event, restoreFocus: boolean = true, skipAnimation: boolean = false): void => {
|
|
796
863
|
if (!state.visible) return;
|
|
797
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
|
+
|
|
798
874
|
// Close any open submenu first
|
|
799
875
|
closeSubmenu();
|
|
800
876
|
|
|
@@ -805,38 +881,99 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
805
881
|
component.element.setAttribute('aria-hidden', 'true');
|
|
806
882
|
component.element.classList.remove(`${component.getClass('menu--visible')}`);
|
|
807
883
|
|
|
884
|
+
// Store anchor reference before potentially removing the menu
|
|
885
|
+
const anchorElement = getAnchorElement();
|
|
886
|
+
|
|
808
887
|
// Remove document events
|
|
809
888
|
document.removeEventListener('click', handleDocumentClick);
|
|
810
889
|
document.removeEventListener('keydown', handleDocumentKeydown);
|
|
811
890
|
window.removeEventListener('resize', handleWindowResize);
|
|
812
891
|
window.removeEventListener('scroll', handleWindowScroll);
|
|
813
892
|
|
|
814
|
-
// Trigger event
|
|
815
|
-
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);
|
|
899
|
+
|
|
900
|
+
// Determine animation duration - for tab navigation we want to close immediately
|
|
901
|
+
const animationDuration = skipAnimation ? 0 : 300;
|
|
816
902
|
|
|
817
|
-
// Remove from DOM after animation completes
|
|
903
|
+
// Remove from DOM after animation completes (or immediately if skipAnimation)
|
|
818
904
|
setTimeout(() => {
|
|
819
905
|
if (component.element.parentNode && !state.visible) {
|
|
820
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
|
+
}
|
|
821
917
|
}
|
|
822
|
-
},
|
|
918
|
+
}, animationDuration);
|
|
823
919
|
};
|
|
824
920
|
|
|
825
921
|
/**
|
|
826
922
|
* Toggles the menu
|
|
827
923
|
*/
|
|
828
|
-
const toggleMenu = (event?: Event): void => {
|
|
924
|
+
const toggleMenu = (event?: Event, interactionType: 'mouse' | 'keyboard' = 'mouse'): void => {
|
|
829
925
|
if (state.visible) {
|
|
830
926
|
closeMenu(event);
|
|
831
927
|
} else {
|
|
832
|
-
|
|
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);
|
|
833
937
|
}
|
|
834
938
|
};
|
|
835
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
|
+
|
|
836
967
|
/**
|
|
837
968
|
* Handles document click
|
|
838
969
|
*/
|
|
839
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
|
+
|
|
840
977
|
// Don't close if clicked inside menu
|
|
841
978
|
if (component.element.contains(e.target as Node)) {
|
|
842
979
|
return;
|
|
@@ -857,7 +994,8 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
857
994
|
*/
|
|
858
995
|
const handleDocumentKeydown = (e: KeyboardEvent): void => {
|
|
859
996
|
if (e.key === 'Escape') {
|
|
860
|
-
|
|
997
|
+
// When closing with Escape, always restore focus
|
|
998
|
+
closeMenu(e, true);
|
|
861
999
|
}
|
|
862
1000
|
};
|
|
863
1001
|
|
|
@@ -899,6 +1037,9 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
899
1037
|
* Handles keydown events on the menu or submenu
|
|
900
1038
|
*/
|
|
901
1039
|
const handleMenuKeydown = (e: KeyboardEvent): void => {
|
|
1040
|
+
// Set keyboard navigation active flag
|
|
1041
|
+
state.keyboardNavActive = true;
|
|
1042
|
+
|
|
902
1043
|
// Determine if this event is from the main menu or a submenu
|
|
903
1044
|
const isSubmenu = state.activeSubmenu && state.activeSubmenu.contains(e.target as Node);
|
|
904
1045
|
|
|
@@ -919,17 +1060,28 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
919
1060
|
focusedItemIndex = items.indexOf(focusedElement);
|
|
920
1061
|
}
|
|
921
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
|
+
|
|
922
1073
|
switch (e.key) {
|
|
923
1074
|
case 'ArrowDown':
|
|
924
1075
|
case 'Down':
|
|
925
1076
|
e.preventDefault();
|
|
926
1077
|
// If no item is active, select the first one
|
|
927
1078
|
if (focusedItemIndex < 0) {
|
|
928
|
-
|
|
1079
|
+
focusItem(0);
|
|
929
1080
|
} else if (focusedItemIndex < items.length - 1) {
|
|
930
|
-
|
|
1081
|
+
focusItem(focusedItemIndex + 1);
|
|
931
1082
|
} else {
|
|
932
|
-
|
|
1083
|
+
// Wrap to first item
|
|
1084
|
+
focusItem(0);
|
|
933
1085
|
}
|
|
934
1086
|
break;
|
|
935
1087
|
|
|
@@ -938,22 +1090,23 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
938
1090
|
e.preventDefault();
|
|
939
1091
|
// If no item is active, select the last one
|
|
940
1092
|
if (focusedItemIndex < 0) {
|
|
941
|
-
items
|
|
1093
|
+
focusItem(items.length - 1);
|
|
942
1094
|
} else if (focusedItemIndex > 0) {
|
|
943
|
-
|
|
1095
|
+
focusItem(focusedItemIndex - 1);
|
|
944
1096
|
} else {
|
|
945
|
-
|
|
1097
|
+
// Wrap to last item
|
|
1098
|
+
focusItem(items.length - 1);
|
|
946
1099
|
}
|
|
947
1100
|
break;
|
|
948
1101
|
|
|
949
1102
|
case 'Home':
|
|
950
1103
|
e.preventDefault();
|
|
951
|
-
|
|
1104
|
+
focusItem(0);
|
|
952
1105
|
break;
|
|
953
1106
|
|
|
954
1107
|
case 'End':
|
|
955
1108
|
e.preventDefault();
|
|
956
|
-
items
|
|
1109
|
+
focusItem(items.length - 1);
|
|
957
1110
|
break;
|
|
958
1111
|
|
|
959
1112
|
case 'Enter':
|
|
@@ -972,7 +1125,20 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
972
1125
|
if (isSubmenu) {
|
|
973
1126
|
// In a submenu, right arrow opens nested submenus
|
|
974
1127
|
if (focusedItemIndex >= 0 && items[focusedItemIndex].classList.contains(`${component.getClass('menu-item--submenu')}`)) {
|
|
975
|
-
|
|
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
|
+
}
|
|
976
1142
|
}
|
|
977
1143
|
} else {
|
|
978
1144
|
// In main menu, right arrow opens a submenu
|
|
@@ -1008,7 +1174,10 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
1008
1174
|
closeSubmenuAtLevel(currentLevel);
|
|
1009
1175
|
|
|
1010
1176
|
// Focus the parent item after closing
|
|
1011
|
-
parentItem
|
|
1177
|
+
if (parentItem) {
|
|
1178
|
+
parentItem.setAttribute('tabindex', '0');
|
|
1179
|
+
parentItem.focus();
|
|
1180
|
+
}
|
|
1012
1181
|
} else {
|
|
1013
1182
|
closeSubmenu();
|
|
1014
1183
|
}
|
|
@@ -1033,25 +1202,105 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
1033
1202
|
closeSubmenuAtLevel(currentLevel);
|
|
1034
1203
|
|
|
1035
1204
|
// Focus the parent item after closing
|
|
1036
|
-
parentItem
|
|
1205
|
+
if (parentItem) {
|
|
1206
|
+
parentItem.setAttribute('tabindex', '0');
|
|
1207
|
+
parentItem.focus();
|
|
1208
|
+
}
|
|
1037
1209
|
} else {
|
|
1038
1210
|
closeSubmenu();
|
|
1039
1211
|
}
|
|
1040
1212
|
} else {
|
|
1041
|
-
// In main menu, Escape closes the entire menu
|
|
1042
|
-
closeMenu(e);
|
|
1213
|
+
// In main menu, Escape closes the entire menu and restores focus to anchor
|
|
1214
|
+
closeMenu(e, true);
|
|
1043
1215
|
}
|
|
1044
1216
|
break;
|
|
1045
1217
|
|
|
1046
1218
|
case 'Tab':
|
|
1047
|
-
//
|
|
1048
|
-
|
|
1049
|
-
|
|
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);
|
|
1050
1261
|
}
|
|
1051
1262
|
break;
|
|
1052
1263
|
}
|
|
1053
1264
|
};
|
|
1054
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
|
+
|
|
1055
1304
|
/**
|
|
1056
1305
|
* Sets up the menu
|
|
1057
1306
|
*/
|
|
@@ -1123,20 +1372,20 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
1123
1372
|
return {
|
|
1124
1373
|
...component,
|
|
1125
1374
|
menu: {
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
+
},
|
|
1140
1389
|
|
|
1141
1390
|
isOpen: () => state.visible,
|
|
1142
1391
|
|
|
@@ -1159,7 +1408,14 @@ export const withController = (config: MenuConfig) => component => {
|
|
|
1159
1408
|
return component;
|
|
1160
1409
|
},
|
|
1161
1410
|
|
|
1162
|
-
getPosition: () => state.position
|
|
1411
|
+
getPosition: () => state.position,
|
|
1412
|
+
|
|
1413
|
+
setSelected: (itemId: string | null) => {
|
|
1414
|
+
updateSelectedState(itemId);
|
|
1415
|
+
return component;
|
|
1416
|
+
},
|
|
1417
|
+
|
|
1418
|
+
getSelected: () => state.selectedItemId
|
|
1163
1419
|
}
|
|
1164
1420
|
};
|
|
1165
1421
|
};
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
// src/components/menu/features/index.ts
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
// Individual feature imports
|
|
4
|
+
import withController from './controller';
|
|
5
|
+
import withAnchor from './anchor';
|
|
6
|
+
import withPosition from './position';
|
|
7
|
+
|
|
8
|
+
// Export features
|
|
9
|
+
export {
|
|
10
|
+
withController,
|
|
11
|
+
withAnchor,
|
|
12
|
+
withPosition
|
|
13
|
+
};
|
|
@@ -334,7 +334,7 @@ export const createPositioner = (component, config: MenuConfig) => {
|
|
|
334
334
|
* @param config - Menu configuration options
|
|
335
335
|
* @returns Component enhancer with positioning functionality
|
|
336
336
|
*/
|
|
337
|
-
|
|
337
|
+
const withPosition = (config: MenuConfig) => component => {
|
|
338
338
|
// Do nothing if no element
|
|
339
339
|
if (!component.element) {
|
|
340
340
|
return component;
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
import { pipe } from '../../core/compose';
|
|
4
4
|
import { createBase, withElement } from '../../core/compose/component';
|
|
5
5
|
import { withEvents, withLifecycle } from '../../core/compose/features';
|
|
6
|
-
|
|
7
|
-
import
|
|
6
|
+
// Import withController directly from the file
|
|
7
|
+
import withController from './features/controller';
|
|
8
|
+
import withAnchor from './features/anchor';
|
|
9
|
+
import withPosition from './features/position';
|
|
8
10
|
import { withAPI } from './api';
|
|
9
11
|
import { MenuConfig, MenuComponent } from './types';
|
|
10
12
|
import { createBaseConfig, getElementConfig, getApiConfig } from './config';
|
|
@@ -343,6 +343,19 @@ export interface MenuComponent {
|
|
|
343
343
|
* @returns Current position
|
|
344
344
|
*/
|
|
345
345
|
getPosition: () => MenuPosition;
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Sets the selected menu item
|
|
349
|
+
* @param itemId - ID of the menu item to mark as selected
|
|
350
|
+
* @returns The menu component for chaining
|
|
351
|
+
*/
|
|
352
|
+
setSelected: (itemId: string) => MenuComponent;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Gets the currently selected menu item's ID
|
|
356
|
+
* @returns ID of the selected menu item or null if none is selected
|
|
357
|
+
*/
|
|
358
|
+
getSelected: () => string | null;
|
|
346
359
|
|
|
347
360
|
/**
|
|
348
361
|
* Adds an event listener to the menu
|
|
@@ -79,7 +79,7 @@ const processMenuItems = (options) => {
|
|
|
79
79
|
};
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
|
-
* Creates a menu for the select component
|
|
82
|
+
* Creates a menu for the select component with improved keyboard navigation
|
|
83
83
|
* @param config - Select configuration
|
|
84
84
|
* @returns Function that enhances a component with menu functionality
|
|
85
85
|
*/
|
|
@@ -116,6 +116,9 @@ export const withMenu = (config: SelectConfig) =>
|
|
|
116
116
|
offset: 0 // Set offset to 0 to eliminate gap between textfield and menu
|
|
117
117
|
});
|
|
118
118
|
|
|
119
|
+
// This flag helps us know if we need to restore focus after menu closes
|
|
120
|
+
let needsFocusRestore = false;
|
|
121
|
+
|
|
119
122
|
// Handle menu selection
|
|
120
123
|
menu.on('select', (event) => {
|
|
121
124
|
// Safely access data properties with proper type checking
|
|
@@ -135,6 +138,12 @@ export const withMenu = (config: SelectConfig) =>
|
|
|
135
138
|
// Update textfield
|
|
136
139
|
component.textfield.setValue(option.text);
|
|
137
140
|
|
|
141
|
+
// Update the selected state in the menu
|
|
142
|
+
menu.setSelected(option.id);
|
|
143
|
+
|
|
144
|
+
// Set flag to restore focus after menu closes
|
|
145
|
+
needsFocusRestore = true;
|
|
146
|
+
|
|
138
147
|
// Emit change event
|
|
139
148
|
if (component.emit) {
|
|
140
149
|
component.emit('change', {
|
|
@@ -149,23 +158,6 @@ export const withMenu = (config: SelectConfig) =>
|
|
|
149
158
|
}
|
|
150
159
|
});
|
|
151
160
|
|
|
152
|
-
// Handle textfield click to open menu
|
|
153
|
-
component.textfield.element.addEventListener('click', (e) => {
|
|
154
|
-
if (!component.textfield.input.disabled) {
|
|
155
|
-
menu.open(e, 'mouse'); // Specify mouse interaction
|
|
156
|
-
|
|
157
|
-
// Emit open event
|
|
158
|
-
if (component.emit) {
|
|
159
|
-
component.emit('open', {
|
|
160
|
-
select: component,
|
|
161
|
-
originalEvent: e,
|
|
162
|
-
preventDefault: () => {},
|
|
163
|
-
defaultPrevented: false
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
|
|
169
161
|
// Add keyboard event listener for textfield
|
|
170
162
|
component.textfield.element.addEventListener('keydown', (e) => {
|
|
171
163
|
if (component.textfield.input.disabled) return;
|
|
@@ -173,7 +165,18 @@ export const withMenu = (config: SelectConfig) =>
|
|
|
173
165
|
// Handle keyboard-based open
|
|
174
166
|
if ((e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') && !menu.isOpen()) {
|
|
175
167
|
e.preventDefault();
|
|
176
|
-
|
|
168
|
+
|
|
169
|
+
// Set flag to restore focus
|
|
170
|
+
needsFocusRestore = true;
|
|
171
|
+
|
|
172
|
+
// Open menu with keyboard interaction
|
|
173
|
+
menu.open(e, 'keyboard');
|
|
174
|
+
|
|
175
|
+
// Ensure textfield keeps focus
|
|
176
|
+
setTimeout(() => {
|
|
177
|
+
// Focus on the textfield to ensure we can capture further keyboard events
|
|
178
|
+
component.textfield.input.focus();
|
|
179
|
+
}, 50);
|
|
177
180
|
|
|
178
181
|
// Emit open event
|
|
179
182
|
if (component.emit) {
|
|
@@ -186,6 +189,7 @@ export const withMenu = (config: SelectConfig) =>
|
|
|
186
189
|
}
|
|
187
190
|
} else if (e.key === 'Escape' && menu.isOpen()) {
|
|
188
191
|
e.preventDefault();
|
|
192
|
+
needsFocusRestore = true;
|
|
189
193
|
menu.close(e);
|
|
190
194
|
}
|
|
191
195
|
});
|
|
@@ -209,13 +213,32 @@ export const withMenu = (config: SelectConfig) =>
|
|
|
209
213
|
// Remove open class from the select component
|
|
210
214
|
component.textfield.element.classList.remove(`${config.prefix || 'mtrl'}-select--open`);
|
|
211
215
|
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
// Restore focus to textfield if needed
|
|
217
|
+
if (needsFocusRestore) {
|
|
218
|
+
// Small delay to ensure menu is fully closed
|
|
219
|
+
setTimeout(() => {
|
|
220
|
+
// Explicitly focus the textfield input
|
|
221
|
+
component.textfield.input.focus();
|
|
222
|
+
|
|
223
|
+
// Keep focused styling
|
|
224
|
+
const PREFIX = config.prefix || 'mtrl';
|
|
225
|
+
component.textfield.element.classList.add(`${PREFIX}-textfield--focused`);
|
|
226
|
+
|
|
227
|
+
if (component.textfield.element.classList.contains(`${PREFIX}-textfield--filled`)) {
|
|
228
|
+
component.textfield.element.classList.add(`${PREFIX}-textfield--filled-focused`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Reset the flag
|
|
232
|
+
needsFocusRestore = false;
|
|
233
|
+
}, 50);
|
|
234
|
+
} else {
|
|
235
|
+
// Only remove focus styling if we're not restoring focus
|
|
236
|
+
const PREFIX = config.prefix || 'mtrl';
|
|
237
|
+
component.textfield.element.classList.remove(`${PREFIX}-textfield--focused`);
|
|
238
|
+
|
|
239
|
+
if (component.textfield.element.classList.contains(`${PREFIX}-textfield--filled`)) {
|
|
240
|
+
component.textfield.element.classList.remove(`${PREFIX}-textfield--filled-focused`);
|
|
241
|
+
}
|
|
219
242
|
}
|
|
220
243
|
|
|
221
244
|
// Emit close event
|
|
@@ -233,20 +256,8 @@ export const withMenu = (config: SelectConfig) =>
|
|
|
233
256
|
const markSelectedMenuItem = () => {
|
|
234
257
|
if (!state.selectedOption) return;
|
|
235
258
|
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
if (!menuList) return;
|
|
239
|
-
|
|
240
|
-
// Find and mark the selected item
|
|
241
|
-
const items = menuList.querySelectorAll(`.${config.prefix || 'mtrl'}-menu-item`);
|
|
242
|
-
items.forEach(item => {
|
|
243
|
-
const itemId = item.getAttribute('data-id');
|
|
244
|
-
if (itemId === state.selectedOption.id) {
|
|
245
|
-
item.classList.add(`${config.prefix || 'mtrl'}-menu-item--selected`);
|
|
246
|
-
} else {
|
|
247
|
-
item.classList.remove(`${config.prefix || 'mtrl'}-menu-item--selected`);
|
|
248
|
-
}
|
|
249
|
-
});
|
|
259
|
+
// Use menu's setSelected method to update the selected state
|
|
260
|
+
menu.setSelected(state.selectedOption.id);
|
|
250
261
|
};
|
|
251
262
|
|
|
252
263
|
// Mark selected item when menu opens
|
|
@@ -269,10 +280,8 @@ export const withMenu = (config: SelectConfig) =>
|
|
|
269
280
|
state.selectedOption = option;
|
|
270
281
|
component.textfield.setValue(option.text);
|
|
271
282
|
|
|
272
|
-
// Update
|
|
273
|
-
|
|
274
|
-
markSelectedMenuItem();
|
|
275
|
-
}
|
|
283
|
+
// Update selected state using menu's setSelected
|
|
284
|
+
menu.setSelected(option.id);
|
|
276
285
|
}
|
|
277
286
|
return component;
|
|
278
287
|
},
|
|
@@ -302,6 +311,11 @@ export const withMenu = (config: SelectConfig) =>
|
|
|
302
311
|
},
|
|
303
312
|
|
|
304
313
|
open: (event?: Event, interactionType: 'mouse' | 'keyboard' = 'mouse') => {
|
|
314
|
+
// Set focus restore flag for keyboard interactions
|
|
315
|
+
if (interactionType === 'keyboard') {
|
|
316
|
+
needsFocusRestore = true;
|
|
317
|
+
}
|
|
318
|
+
|
|
305
319
|
menu.open(event, interactionType);
|
|
306
320
|
return component;
|
|
307
321
|
},
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,7 @@ import createBadge from './components/badge';
|
|
|
12
12
|
import createBottomAppBar from './components/bottom-app-bar';
|
|
13
13
|
import createButton from './components/button';
|
|
14
14
|
import createCard from './components/card';
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
16
|
createCardContent,
|
|
17
17
|
createCardHeader,
|
|
18
18
|
createCardActions,
|
|
@@ -87,48 +87,4 @@ export {
|
|
|
87
87
|
createTimePicker,
|
|
88
88
|
createTopAppBar,
|
|
89
89
|
createTooltip
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
// Export all "f*" aliases
|
|
93
|
-
export {
|
|
94
|
-
createElement as fElement,
|
|
95
|
-
createLayout as fLayout,
|
|
96
|
-
createBadge as fBadge,
|
|
97
|
-
createBottomAppBar as fBottomAppBar,
|
|
98
|
-
createButton as fButton,
|
|
99
|
-
createCard as fCard,
|
|
100
|
-
createCardContent as fCardContent,
|
|
101
|
-
createCardHeader as fCardHeader,
|
|
102
|
-
createCardActions as fCardActions,
|
|
103
|
-
createCardMedia as fCardMedia,
|
|
104
|
-
createCarousel as fCarousel,
|
|
105
|
-
createCheckbox as fCheckbox,
|
|
106
|
-
createChip as fChip,
|
|
107
|
-
createChips as fChips,
|
|
108
|
-
createDatePicker as fDatePicker,
|
|
109
|
-
createDialog as fDialog,
|
|
110
|
-
createDivider as fDivider,
|
|
111
|
-
createFab as fFab,
|
|
112
|
-
createExtendedFab as fExtendedFab,
|
|
113
|
-
createList as fList,
|
|
114
|
-
createListItem as fListItem,
|
|
115
|
-
createMenu as fMenu,
|
|
116
|
-
createNavigation as fNavigation,
|
|
117
|
-
createNavigationSystem as fNavigationSystem,
|
|
118
|
-
createProgress as fProgress,
|
|
119
|
-
createRadios as fRadios,
|
|
120
|
-
createSearch as fSearch,
|
|
121
|
-
createSelect as fSelect,
|
|
122
|
-
createSegmentedButton as fSegmentedButton,
|
|
123
|
-
createSegment as fSegment,
|
|
124
|
-
createSheet as fSheet,
|
|
125
|
-
createSlider as fSlider,
|
|
126
|
-
createSnackbar as fSnackbar,
|
|
127
|
-
createSwitch as fSwitch,
|
|
128
|
-
createTabs as fTabs,
|
|
129
|
-
createTab as fTab,
|
|
130
|
-
createTextfield as fTextfield,
|
|
131
|
-
createTimePicker as fTimePicker,
|
|
132
|
-
createTopAppBar as fTopAppBar,
|
|
133
|
-
createTooltip as fTooltip
|
|
134
90
|
};
|
|
@@ -72,6 +72,7 @@ $component: 'mtrl-menu';
|
|
|
72
72
|
position: relative;
|
|
73
73
|
min-height: 48px;
|
|
74
74
|
padding: 12px 16px;
|
|
75
|
+
padding-right: 42px;
|
|
75
76
|
cursor: pointer;
|
|
76
77
|
user-select: none;
|
|
77
78
|
color: t.color('on-surface');
|
|
@@ -107,14 +108,8 @@ $component: 'mtrl-menu';
|
|
|
107
108
|
transform: translateY(-50%);
|
|
108
109
|
width: 24px;
|
|
109
110
|
height: 24px;
|
|
110
|
-
mask
|
|
111
|
-
-webkit-mask
|
|
112
|
-
mask-size: contain;
|
|
113
|
-
-webkit-mask-size: contain;
|
|
114
|
-
mask-repeat: no-repeat;
|
|
115
|
-
-webkit-mask-repeat: no-repeat;
|
|
116
|
-
mask-position: center;
|
|
117
|
-
-webkit-mask-position: center;
|
|
111
|
+
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M10 17l5-5-5-5v10z'/%3E%3C/svg%3E") center / contain no-repeat;
|
|
112
|
+
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M10 17l5-5-5-5v10z'/%3E%3C/svg%3E") center / contain no-repeat;
|
|
118
113
|
background-color: currentColor;
|
|
119
114
|
opacity: 0.87;
|
|
120
115
|
}
|
|
@@ -137,19 +132,18 @@ $component: 'mtrl-menu';
|
|
|
137
132
|
// Selected state for select component
|
|
138
133
|
&--selected {
|
|
139
134
|
color: t.color('primary');
|
|
140
|
-
font-weight: 500;
|
|
141
135
|
|
|
142
136
|
&::after {
|
|
143
137
|
content: "";
|
|
144
|
-
display: block;
|
|
145
138
|
position: absolute;
|
|
146
139
|
right: 12px;
|
|
140
|
+
top: 50%;
|
|
141
|
+
transform: translateY(-50%);
|
|
147
142
|
width: 18px;
|
|
148
143
|
height: 18px;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
background-
|
|
152
|
-
color: t.color('primary');
|
|
144
|
+
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpolyline points='20 6 9 17 4 12' stroke='white' stroke-width='2' fill='none'/%3E%3C/svg%3E") center / contain no-repeat;
|
|
145
|
+
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpolyline points='20 6 9 17 4 12' stroke='white' stroke-width='2' fill='none'/%3E%3C/svg%3E") center / contain no-repeat;
|
|
146
|
+
background-color: currentColor;
|
|
153
147
|
}
|
|
154
148
|
}
|
|
155
149
|
|
|
@@ -125,10 +125,17 @@ $component: '#{base.$prefix}-select';
|
|
|
125
125
|
right: 12px;
|
|
126
126
|
width: 18px;
|
|
127
127
|
height: 18px;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
128
|
+
// Use mask-image instead of background-image
|
|
129
|
+
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
|
|
130
|
+
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
|
|
131
|
+
mask-size: contain;
|
|
132
|
+
-webkit-mask-size: contain;
|
|
133
|
+
mask-repeat: no-repeat;
|
|
134
|
+
-webkit-mask-repeat: no-repeat;
|
|
135
|
+
mask-position: center;
|
|
136
|
+
-webkit-mask-position: center;
|
|
137
|
+
// Use currentColor to match the text color
|
|
138
|
+
background-color: currentColor;
|
|
132
139
|
}
|
|
133
140
|
}
|
|
134
141
|
}
|