mtrl 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +2 -2
  2. package/src/components/button/api.ts +16 -0
  3. package/src/components/button/types.ts +9 -0
  4. package/src/components/menu/api.ts +61 -22
  5. package/src/components/menu/config.ts +10 -8
  6. package/src/components/menu/features/anchor.ts +254 -19
  7. package/src/components/menu/features/controller.ts +724 -271
  8. package/src/components/menu/features/index.ts +11 -2
  9. package/src/components/menu/features/position.ts +353 -0
  10. package/src/components/menu/index.ts +5 -5
  11. package/src/components/menu/menu.ts +21 -61
  12. package/src/components/menu/types.ts +30 -16
  13. package/src/components/select/api.ts +78 -0
  14. package/src/components/select/config.ts +76 -0
  15. package/src/components/select/features.ts +331 -0
  16. package/src/components/select/index.ts +38 -0
  17. package/src/components/select/select.ts +73 -0
  18. package/src/components/select/types.ts +355 -0
  19. package/src/components/textfield/api.ts +78 -6
  20. package/src/components/textfield/features/index.ts +17 -0
  21. package/src/components/textfield/features/leading-icon.ts +127 -0
  22. package/src/components/textfield/features/placement.ts +149 -0
  23. package/src/components/textfield/features/prefix-text.ts +107 -0
  24. package/src/components/textfield/features/suffix-text.ts +100 -0
  25. package/src/components/textfield/features/supporting-text.ts +113 -0
  26. package/src/components/textfield/features/trailing-icon.ts +108 -0
  27. package/src/components/textfield/textfield.ts +51 -15
  28. package/src/components/textfield/types.ts +70 -0
  29. package/src/core/collection/adapters/base.ts +62 -0
  30. package/src/core/collection/collection.ts +300 -0
  31. package/src/core/collection/index.ts +57 -0
  32. package/src/core/collection/list-manager.ts +333 -0
  33. package/src/index.ts +4 -45
  34. package/src/styles/abstract/_variables.scss +18 -0
  35. package/src/styles/components/_button.scss +21 -5
  36. package/src/styles/components/{_chip.scss → _chips.scss} +118 -4
  37. package/src/styles/components/_menu.scss +97 -24
  38. package/src/styles/components/_select.scss +272 -0
  39. package/src/styles/components/_textfield.scss +233 -42
  40. package/src/styles/main.scss +2 -1
  41. package/src/components/textfield/features.ts +0 -322
  42. package/src/core/collection/adapters/base.js +0 -26
  43. package/src/core/collection/collection.js +0 -259
  44. package/src/core/collection/list-manager.js +0 -157
  45. /package/src/core/collection/adapters/{route.js → route.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtrl",
3
- "version": "0.3.6",
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",
@@ -123,6 +123,22 @@ export const withAPI = ({ disabled, lifecycle }: ApiOptions) =>
123
123
  return component.icon.getIcon();
124
124
  },
125
125
 
126
+ /**
127
+ * Sets the active state of the button
128
+ * Used to visually indicate the button's active state, such as when it has a menu open
129
+ *
130
+ * @param active - Whether the button should appear active
131
+ * @returns Button component for method chaining
132
+ */
133
+ setActive(active: boolean) {
134
+ if (active) {
135
+ component.element.classList.add(`${component.getClass('button')}--active`);
136
+ } else {
137
+ component.element.classList.remove(`${component.getClass('button')}--active`);
138
+ }
139
+ return this;
140
+ },
141
+
126
142
  /**
127
143
  * Sets the button's aria-label attribute for accessibility
128
144
  * @param label - Accessible label text
@@ -249,6 +249,15 @@ export interface ButtonComponent {
249
249
  */
250
250
  updateCircularStyle: () => void;
251
251
 
252
+ /**
253
+ * Sets the active state of the button
254
+ * Used to visually indicate the button's active state, such as when it has a menu open
255
+ *
256
+ * @param active - Whether the button should appear active
257
+ * @returns The button component for chaining
258
+ */
259
+ setActive: (active: boolean) => ButtonComponent;
260
+
252
261
  /**
253
262
  * Adds an event listener to the button
254
263
  * @param event - Event name ('click', 'focus', etc.)
@@ -1,6 +1,6 @@
1
1
  // src/components/menu/api.ts
2
2
 
3
- import { MenuComponent, MenuContent, MenuPlacement, MenuEvent, MenuSelectEvent } from './types';
3
+ import { MenuComponent, MenuContent, MenuPosition, MenuEvent, MenuSelectEvent } from './types';
4
4
 
5
5
  /**
6
6
  * API configuration options for menu component
@@ -9,14 +9,16 @@ import { MenuComponent, MenuContent, MenuPlacement, 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
- setPlacement: (placement: MenuPlacement) => any;
19
- getPlacement: () => MenuPlacement;
18
+ setPosition: (position: MenuPosition) => any;
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,18 +55,27 @@ 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,
60
62
 
63
+
61
64
  /**
62
65
  * Opens the menu
63
66
  * @param event - Optional event that triggered the open
67
+ * @param interactionType - The type of interaction that triggered the open ('mouse' or 'keyboard')
64
68
  * @returns Menu component for chaining
65
69
  */
66
- open(event?: Event) {
67
- menu.open(event);
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
+
78
+ menu.open(event, interactionType);
68
79
  return this;
69
80
  },
70
81
 
@@ -73,18 +84,28 @@ export const withAPI = ({ menu, anchor, events, lifecycle }: ApiOptions) =>
73
84
  * @param event - Optional event that triggered the close
74
85
  * @returns Menu component for chaining
75
86
  */
76
- close(event?: Event) {
77
- menu.close(event);
87
+ close(event?: Event, restoreFocus: boolean = true, skipAnimation: boolean = false) {
88
+ menu.close(event, restoreFocus, skipAnimation);
78
89
  return this;
79
90
  },
80
91
 
81
92
  /**
82
93
  * Toggles the menu's open state
83
94
  * @param event - Optional event that triggered the toggle
95
+ * @param interactionType - The type of interaction that triggered the toggle
84
96
  * @returns Menu component for chaining
85
97
  */
86
- toggle(event?: Event) {
87
- 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);
88
109
  return this;
89
110
  },
90
111
 
@@ -133,21 +154,39 @@ export const withAPI = ({ menu, anchor, events, lifecycle }: ApiOptions) =>
133
154
  },
134
155
 
135
156
  /**
136
- * Updates the menu's placement
137
- * @param placement - New placement value
157
+ * Updates the menu's position
158
+ * @param position - New position value
159
+ * @returns Menu component for chaining
160
+ */
161
+ setPosition(position: MenuPosition) {
162
+ menu.setPosition(position);
163
+ return this;
164
+ },
165
+
166
+ /**
167
+ * Gets the current menu position
168
+ * @returns Current position
169
+ */
170
+ getPosition() {
171
+ return menu.getPosition();
172
+ },
173
+
174
+ /**
175
+ * Sets the selected menu item
176
+ * @param itemId - ID of the menu item to mark as selected
138
177
  * @returns Menu component for chaining
139
178
  */
140
- setPlacement(placement: MenuPlacement) {
141
- menu.setPlacement(placement);
179
+ setSelected(itemId: string) {
180
+ menu.setSelected(itemId);
142
181
  return this;
143
182
  },
144
183
 
145
184
  /**
146
- * Gets the current menu placement
147
- * @returns Current placement
185
+ * Gets the currently selected menu item's ID
186
+ * @returns ID of the selected menu item or null if none is selected
148
187
  */
149
- getPlacement() {
150
- return menu.getPlacement();
188
+ getSelected() {
189
+ return menu.getSelected();
151
190
  },
152
191
 
153
192
  /**
@@ -188,4 +227,4 @@ export const withAPI = ({ menu, anchor, events, lifecycle }: ApiOptions) =>
188
227
  }
189
228
  });
190
229
 
191
- export default withAPI;
230
+ export { withAPI };
@@ -5,7 +5,7 @@ import {
5
5
  createElementConfig,
6
6
  BaseComponentConfig
7
7
  } from '../../core/config/component-config';
8
- import { MenuConfig, MENU_PLACEMENT } from './types';
8
+ import { MenuConfig, MENU_POSITION } from './types';
9
9
 
10
10
  /**
11
11
  * Default configuration for the Menu component
@@ -15,12 +15,12 @@ import { MenuConfig, MENU_PLACEMENT } from './types';
15
15
  */
16
16
  export const defaultConfig: MenuConfig = {
17
17
  items: [],
18
- placement: MENU_PLACEMENT.BOTTOM_START,
18
+ position: MENU_POSITION.BOTTOM_START,
19
19
  closeOnSelect: true,
20
20
  closeOnClickOutside: true,
21
21
  closeOnEscape: true,
22
22
  openSubmenuOnHover: true,
23
- offset: 8,
23
+ offset: 0,
24
24
  autoFlip: true,
25
25
  visible: false
26
26
  };
@@ -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
- setPlacement: (placement) => component.menu?.setPlacement(placement),
110
- getPlacement: () => component.menu?.getPlacement()
109
+ setPosition: (position) => component.menu?.setPosition(position),
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,23 +9,40 @@ 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
- anchorElement: null as HTMLElement
34
+ anchorElement: null as HTMLElement,
35
+ anchorComponent: null as any,
36
+ activeClass: '' // Store the appropriate active class based on element type
21
37
  };
22
38
 
23
39
  /**
24
- * Resolves the anchor element from string or direct reference
40
+ * Resolves the anchor element from string, direct reference, or component
25
41
  */
26
- const resolveAnchor = (anchor: HTMLElement | string): HTMLElement => {
42
+ const resolveAnchor = (anchor: HTMLElement | string | { element: HTMLElement }): HTMLElement => {
27
43
  if (!anchor) return null;
28
44
 
45
+ // Handle string selector
29
46
  if (typeof anchor === 'string') {
30
47
  const element = document.querySelector(anchor);
31
48
  if (!element) {
@@ -34,14 +51,41 @@ export const withAnchor = (config: MenuConfig) => component => {
34
51
  }
35
52
  return element as HTMLElement;
36
53
  }
54
+
55
+ // Handle component with element property
56
+ if (typeof anchor === 'object' && anchor !== null && 'element' in anchor) {
57
+ return anchor.element;
58
+ }
59
+
60
+ // Handle direct HTML element
61
+ return anchor as HTMLElement;
62
+ };
37
63
 
38
- return anchor;
64
+ /**
65
+ * Determine the appropriate active class based on element type
66
+ */
67
+ const determineActiveClass = (element: HTMLElement): string => {
68
+ // Check if this is one of our component types
69
+ const classPrefix = component.getClass('').split('-')[0];
70
+
71
+ // Check element tag and classes to determine appropriate active class
72
+ if (element.tagName === 'BUTTON') {
73
+ return `${classPrefix}-button--active`;
74
+ } else if (element.classList.contains(`${classPrefix}-chip`)) {
75
+ return `${classPrefix}-chip--selected`;
76
+ } else if (element.classList.contains(`${classPrefix}-textfield`) ||
77
+ element.classList.contains(`${classPrefix}-select`)) {
78
+ return `${classPrefix}-textfield--focused`;
79
+ } else {
80
+ // Default active class for other elements
81
+ return `${classPrefix}-menu-anchor--active`;
82
+ }
39
83
  };
40
84
 
41
85
  /**
42
86
  * Sets up anchor click handler for toggling menu
43
87
  */
44
- const setupAnchorEvents = (anchorElement: HTMLElement): void => {
88
+ const setupAnchorEvents = (anchorElement: HTMLElement, originalAnchor?: any): void => {
45
89
  if (!anchorElement) return;
46
90
 
47
91
  // Remove previously attached event if any
@@ -49,12 +93,28 @@ export const withAnchor = (config: MenuConfig) => component => {
49
93
  cleanup();
50
94
  }
51
95
 
52
- // Store reference
96
+ // Store references
53
97
  state.anchorElement = anchorElement;
98
+
99
+ // Store reference to component if it was provided
100
+ if (originalAnchor && typeof originalAnchor === 'object' && 'element' in originalAnchor) {
101
+ state.anchorComponent = originalAnchor;
102
+ } else {
103
+ state.anchorComponent = null;
104
+ }
105
+
106
+ // Determine the appropriate active class for this anchor
107
+ state.activeClass = determineActiveClass(anchorElement);
54
108
 
55
109
  // Add click handler
56
110
  anchorElement.addEventListener('click', handleAnchorClick);
57
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
+
58
118
  // Add ARIA attributes
59
119
  anchorElement.setAttribute('aria-haspopup', 'true');
60
120
  anchorElement.setAttribute('aria-expanded', 'false');
@@ -70,25 +130,161 @@ export const withAnchor = (config: MenuConfig) => component => {
70
130
  anchorElement.setAttribute('aria-controls', menuId);
71
131
  };
72
132
 
133
+ /**
134
+ * Applies active visual state to anchor
135
+ */
136
+ const setAnchorActive = (active: boolean): void => {
137
+ if (!state.anchorElement) return;
138
+
139
+ // For component with setActive method (our button component has this)
140
+ if (state.anchorComponent && typeof state.anchorComponent.setActive === 'function') {
141
+ state.anchorComponent.setActive(active);
142
+ }
143
+ // For component with .selected property (like our chip component)
144
+ else if (state.anchorComponent && 'selected' in state.anchorComponent) {
145
+ state.anchorComponent.selected = active;
146
+ }
147
+ // Standard DOM element fallback
148
+ else if (state.anchorElement.classList) {
149
+ if (active) {
150
+ // Add the appropriate active class
151
+ state.anchorElement.classList.add(state.activeClass);
152
+ } else {
153
+ // Remove active class
154
+ state.anchorElement.classList.remove(state.activeClass);
155
+ }
156
+ }
157
+ };
158
+
73
159
  /**
74
160
  * Handles anchor element click
75
161
  */
76
162
  const handleAnchorClick = (e: MouseEvent): void => {
77
163
  e.preventDefault();
78
164
 
79
- // Toggle menu visibility
165
+ // Toggle menu visibility with mouse interaction type
80
166
  if (component.menu) {
81
167
  const isOpen = component.menu.isOpen();
82
168
 
83
169
  if (isOpen) {
84
170
  component.menu.close(e);
85
- state.anchorElement.setAttribute('aria-expanded', 'false');
86
171
  } else {
87
- component.menu.open(e);
88
- state.anchorElement.setAttribute('aria-expanded', 'true');
172
+ component.menu.open(e, 'mouse');
89
173
  }
90
174
  }
91
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
+ };
92
288
 
93
289
  /**
94
290
  * Removes event listeners from anchor
@@ -96,15 +292,25 @@ export const withAnchor = (config: MenuConfig) => component => {
96
292
  const cleanup = (): void => {
97
293
  if (state.anchorElement) {
98
294
  state.anchorElement.removeEventListener('click', handleAnchorClick);
295
+ state.anchorElement.removeEventListener('keydown', handleAnchorKeydown);
296
+ state.anchorElement.removeEventListener('blur', handleAnchorBlur);
99
297
  state.anchorElement.removeAttribute('aria-haspopup');
100
298
  state.anchorElement.removeAttribute('aria-expanded');
101
299
  state.anchorElement.removeAttribute('aria-controls');
300
+
301
+ // Clean up active state if present
302
+ setAnchorActive(false);
102
303
  }
304
+
305
+ // Reset state
306
+ state.anchorComponent = null;
307
+ state.activeClass = '';
103
308
  };
104
309
 
105
310
  // Initialize with provided anchor
106
- const initialAnchor = resolveAnchor(config.anchor);
107
- setupAnchorEvents(initialAnchor);
311
+ const initialAnchor = config.anchor;
312
+ const initialElement = resolveAnchor(initialAnchor);
313
+ setupAnchorEvents(initialElement, initialAnchor);
108
314
 
109
315
  // Register with lifecycle if available
110
316
  if (component.lifecycle) {
@@ -119,12 +325,31 @@ export const withAnchor = (config: MenuConfig) => component => {
119
325
  component.on('open', () => {
120
326
  if (state.anchorElement) {
121
327
  state.anchorElement.setAttribute('aria-expanded', 'true');
328
+ setAnchorActive(true);
122
329
  }
123
330
  });
124
331
 
125
- component.on('close', () => {
332
+ /**
333
+ * Update the event listener for menu close event to ensure focus restoration
334
+ */
335
+ component.on('close', (event) => {
126
336
  if (state.anchorElement) {
337
+ // Always update ARIA attributes
127
338
  state.anchorElement.setAttribute('aria-expanded', 'false');
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
+ }
128
353
  }
129
354
  });
130
355
 
@@ -134,13 +359,13 @@ export const withAnchor = (config: MenuConfig) => component => {
134
359
  anchor: {
135
360
  /**
136
361
  * Sets a new anchor element
137
- * @param anchor - New anchor element or selector
362
+ * @param anchor - New anchor element, selector, or component
138
363
  * @returns Component for chaining
139
364
  */
140
- setAnchor(anchor: HTMLElement | string) {
141
- const newAnchor = resolveAnchor(anchor);
142
- if (newAnchor) {
143
- setupAnchorEvents(newAnchor);
365
+ setAnchor(anchor: HTMLElement | string | { element: HTMLElement }) {
366
+ const newElement = resolveAnchor(anchor);
367
+ if (newElement) {
368
+ setupAnchorEvents(newElement, anchor);
144
369
  }
145
370
  return component;
146
371
  },
@@ -151,6 +376,16 @@ export const withAnchor = (config: MenuConfig) => component => {
151
376
  */
152
377
  getAnchor() {
153
378
  return state.anchorElement;
379
+ },
380
+
381
+ /**
382
+ * Sets the active state of the anchor
383
+ * @param active - Whether anchor should appear active
384
+ * @returns Component for chaining
385
+ */
386
+ setActive(active: boolean) {
387
+ setAnchorActive(active);
388
+ return component;
154
389
  }
155
390
  }
156
391
  };