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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtrl",
3
- "version": "0.3.7",
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
- export const withAPI = ({ menu, anchor, events, lifecycle }: ApiOptions) =>
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
- menu.toggle(event);
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 default withAPI;
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
- export const withAnchor = (config: MenuConfig) => component => {
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
- component.on('close', () => {
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
- export const withController = (config: MenuConfig) => component => {
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
- clearSubmenuTimer();
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
- handleSubmenuLeave(e);
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', {}, event);
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
- }, 300); // Match the animation duration in CSS
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
- openMenu(event);
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
- closeMenu(e);
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
- items[0].focus();
1079
+ focusItem(0);
929
1080
  } else if (focusedItemIndex < items.length - 1) {
930
- items[focusedItemIndex + 1].focus();
1081
+ focusItem(focusedItemIndex + 1);
931
1082
  } else {
932
- items[0].focus();
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[items.length - 1].focus();
1093
+ focusItem(items.length - 1);
942
1094
  } else if (focusedItemIndex > 0) {
943
- items[focusedItemIndex - 1].focus();
1095
+ focusItem(focusedItemIndex - 1);
944
1096
  } else {
945
- items[items.length - 1].focus();
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
- items[0].focus();
1104
+ focusItem(0);
952
1105
  break;
953
1106
 
954
1107
  case 'End':
955
1108
  e.preventDefault();
956
- items[items.length - 1].focus();
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
- items[focusedItemIndex].click();
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.focus();
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.focus();
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
- // Close the menu when tabbing out
1048
- if (!isSubmenu) {
1049
- closeMenu();
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
- open: (event) => {
1127
- openMenu(event);
1128
- return component;
1129
- },
1130
-
1131
- close: (event) => {
1132
- closeMenu(event);
1133
- return component;
1134
- },
1135
-
1136
- toggle: (event) => {
1137
- toggleMenu(event);
1138
- return component;
1139
- },
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
- export { default as withController } from './controller';
4
- export { default as withAnchor } from './anchor';
5
- export { default as withPosition } from './position';
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
- export const withPosition = (config: MenuConfig) => component => {
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
- import { withController, withAnchor } from './features';
7
- import { withPosition } from './features/position';
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
- menu.open(e, 'keyboard'); // Specify keyboard interaction
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
- // Remove focused class from the textfield
213
- const PREFIX = config.prefix || 'mtrl';
214
- component.textfield.element.classList.remove(`${PREFIX}-textfield--focused`);
215
-
216
- // Remove filled focus class if present
217
- if (component.textfield.element.classList.contains(`${PREFIX}-textfield--filled`)) {
218
- component.textfield.element.classList.remove(`${PREFIX}-textfield--filled-focused`);
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
- // Get all menu items
237
- const menuList = menu.element.querySelector(`.${config.prefix || 'mtrl'}-menu-list`);
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 menu item if menu is open
273
- if (menu.isOpen()) {
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-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24px' viewBox='0 0 24 24' width='24px'%3E%3Cpath d='M0 0h24v24H0V0z' fill='none'/%3E%3Cpath d='M10 17l5-5-5-5v10z'/%3E%3C/svg%3E");
111
- -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' height='24px' viewBox='0 0 24 24' width='24px'%3E%3Cpath d='M0 0h24v24H0V0z' fill='none'/%3E%3Cpath d='M10 17l5-5-5-5v10z'/%3E%3C/svg%3E");
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
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
150
- background-repeat: no-repeat;
151
- background-position: center;
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
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");
129
- background-repeat: no-repeat;
130
- background-position: center;
131
- color: t.color('primary');
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
  }