mtrl 0.3.6 → 0.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/components/button/api.ts +16 -0
- package/src/components/button/types.ts +9 -0
- package/src/components/menu/api.ts +61 -22
- package/src/components/menu/config.ts +10 -8
- package/src/components/menu/features/anchor.ts +254 -19
- package/src/components/menu/features/controller.ts +724 -271
- package/src/components/menu/features/index.ts +11 -2
- package/src/components/menu/features/position.ts +353 -0
- package/src/components/menu/index.ts +5 -5
- package/src/components/menu/menu.ts +21 -61
- package/src/components/menu/types.ts +30 -16
- package/src/components/select/api.ts +78 -0
- package/src/components/select/config.ts +76 -0
- package/src/components/select/features.ts +331 -0
- package/src/components/select/index.ts +38 -0
- package/src/components/select/select.ts +73 -0
- package/src/components/select/types.ts +355 -0
- package/src/components/textfield/api.ts +78 -6
- package/src/components/textfield/features/index.ts +17 -0
- package/src/components/textfield/features/leading-icon.ts +127 -0
- package/src/components/textfield/features/placement.ts +149 -0
- package/src/components/textfield/features/prefix-text.ts +107 -0
- package/src/components/textfield/features/suffix-text.ts +100 -0
- package/src/components/textfield/features/supporting-text.ts +113 -0
- package/src/components/textfield/features/trailing-icon.ts +108 -0
- package/src/components/textfield/textfield.ts +51 -15
- package/src/components/textfield/types.ts +70 -0
- package/src/core/collection/adapters/base.ts +62 -0
- package/src/core/collection/collection.ts +300 -0
- package/src/core/collection/index.ts +57 -0
- package/src/core/collection/list-manager.ts +333 -0
- package/src/index.ts +4 -45
- package/src/styles/abstract/_variables.scss +18 -0
- package/src/styles/components/_button.scss +21 -5
- package/src/styles/components/{_chip.scss → _chips.scss} +118 -4
- package/src/styles/components/_menu.scss +97 -24
- package/src/styles/components/_select.scss +272 -0
- package/src/styles/components/_textfield.scss +233 -42
- package/src/styles/main.scss +2 -1
- package/src/components/textfield/features.ts +0 -322
- package/src/core/collection/adapters/base.js +0 -26
- package/src/core/collection/collection.js +0 -259
- package/src/core/collection/list-manager.js +0 -157
- /package/src/core/collection/adapters/{route.js → route.ts} +0 -0
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",
|
|
@@ -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,
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
137
|
-
* @param
|
|
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
|
-
|
|
141
|
-
menu.
|
|
179
|
+
setSelected(itemId: string) {
|
|
180
|
+
menu.setSelected(itemId);
|
|
142
181
|
return this;
|
|
143
182
|
},
|
|
144
183
|
|
|
145
184
|
/**
|
|
146
|
-
* Gets the
|
|
147
|
-
* @returns
|
|
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
|
-
|
|
150
|
-
return menu.
|
|
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
|
|
230
|
+
export { withAPI };
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
createElementConfig,
|
|
6
6
|
BaseComponentConfig
|
|
7
7
|
} from '../../core/config/component-config';
|
|
8
|
-
import { MenuConfig,
|
|
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
|
-
|
|
18
|
+
position: MENU_POSITION.BOTTOM_START,
|
|
19
19
|
closeOnSelect: true,
|
|
20
20
|
closeOnClickOutside: true,
|
|
21
21
|
closeOnEscape: true,
|
|
22
22
|
openSubmenuOnHover: true,
|
|
23
|
-
offset:
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
107
|
-
|
|
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
|
-
|
|
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
|
|
362
|
+
* @param anchor - New anchor element, selector, or component
|
|
138
363
|
* @returns Component for chaining
|
|
139
364
|
*/
|
|
140
|
-
setAnchor(anchor: HTMLElement | string) {
|
|
141
|
-
const
|
|
142
|
-
if (
|
|
143
|
-
setupAnchorEvents(
|
|
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
|
};
|