mtrl 0.1.3 → 0.2.0
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/README.md +70 -22
- package/index.ts +33 -0
- package/package.json +14 -5
- package/src/components/button/{styles.scss → _styles.scss} +2 -2
- package/src/components/button/api.ts +89 -0
- package/src/components/button/button.ts +50 -0
- package/src/components/button/config.ts +75 -0
- package/src/components/button/constants.ts +17 -0
- package/src/components/button/index.ts +4 -0
- package/src/components/button/types.ts +118 -0
- package/src/components/card/{styles.scss → _styles.scss} +79 -7
- package/src/components/card/{actions.js → actions.ts} +15 -18
- package/src/components/card/{api.js → api.ts} +33 -33
- package/src/components/card/card.ts +41 -0
- package/src/components/card/config.ts +99 -0
- package/src/components/card/{constants.js → constants.ts} +11 -10
- package/src/components/card/{content.js → content.ts} +15 -18
- package/src/components/card/{features.js → features.ts} +104 -94
- package/src/components/card/{header.js → header.ts} +21 -25
- package/src/components/card/index.ts +19 -0
- package/src/components/card/media.ts +52 -0
- package/src/components/card/types.ts +174 -0
- package/src/components/checkbox/api.ts +82 -0
- package/src/components/checkbox/checkbox.ts +75 -0
- package/src/components/checkbox/config.ts +90 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/checkbox/types.ts +146 -0
- package/src/components/chip/_styles.scss +372 -0
- package/src/components/chip/api.ts +115 -0
- package/src/components/chip/chip-set.ts +225 -0
- package/src/components/chip/chip.ts +82 -0
- package/src/components/chip/config.ts +92 -0
- package/src/components/chip/constants.ts +38 -0
- package/src/components/chip/index.ts +4 -0
- package/src/components/chip/types.ts +172 -0
- package/src/components/list/api.ts +72 -0
- package/src/components/list/config.ts +43 -0
- package/src/components/list/{constants.js → constants.ts} +34 -7
- package/src/components/list/features.ts +224 -0
- package/src/components/list/index.ts +14 -0
- package/src/components/list/list-item.ts +120 -0
- package/src/components/list/list.ts +37 -0
- package/src/components/list/types.ts +179 -0
- package/src/components/list/utils.ts +47 -0
- package/src/components/menu/api.ts +119 -0
- package/src/components/menu/config.ts +54 -0
- package/src/components/menu/constants.ts +154 -0
- package/src/components/menu/features/items-manager.ts +457 -0
- package/src/components/menu/features/keyboard-navigation.ts +133 -0
- package/src/components/menu/features/positioning.ts +127 -0
- package/src/components/menu/features/{visibility.js → visibility.ts} +66 -64
- package/src/components/menu/index.ts +14 -0
- package/src/components/menu/menu-item.ts +43 -0
- package/src/components/menu/menu.ts +53 -0
- package/src/components/menu/types.ts +178 -0
- package/src/components/navigation/api.ts +79 -0
- package/src/components/navigation/config.ts +61 -0
- package/src/components/navigation/{constants.js → constants.ts} +10 -10
- package/src/components/navigation/index.ts +14 -0
- package/src/components/navigation/nav-item.ts +148 -0
- package/src/components/navigation/navigation.ts +50 -0
- package/src/components/navigation/types.ts +212 -0
- package/src/components/progress/_styles.scss +204 -0
- package/src/components/progress/api.ts +179 -0
- package/src/components/progress/config.ts +124 -0
- package/src/components/progress/constants.ts +43 -0
- package/src/components/progress/index.ts +5 -0
- package/src/components/progress/progress.ts +163 -0
- package/src/components/progress/types.ts +102 -0
- package/src/components/snackbar/api.ts +162 -0
- package/src/components/snackbar/config.ts +62 -0
- package/src/components/snackbar/{constants.js → constants.ts} +21 -4
- package/src/components/snackbar/features.ts +76 -0
- package/src/components/snackbar/index.ts +4 -0
- package/src/components/snackbar/position.ts +71 -0
- package/src/components/snackbar/queue.ts +76 -0
- package/src/components/snackbar/snackbar.ts +60 -0
- package/src/components/snackbar/types.ts +58 -0
- package/src/components/switch/api.ts +77 -0
- package/src/components/switch/config.ts +74 -0
- package/src/components/switch/index.ts +4 -0
- package/src/components/switch/switch.ts +52 -0
- package/src/components/switch/types.ts +142 -0
- package/src/components/textfield/api.ts +72 -0
- package/src/components/textfield/config.ts +54 -0
- package/src/components/textfield/{constants.js → constants.ts} +38 -5
- package/src/components/textfield/index.ts +4 -0
- package/src/components/textfield/textfield.ts +50 -0
- package/src/components/textfield/types.ts +139 -0
- package/src/core/compose/base.ts +43 -0
- package/src/core/compose/component.ts +247 -0
- package/src/core/compose/features/checkable.ts +155 -0
- package/src/core/compose/features/disabled.ts +116 -0
- package/src/core/compose/features/events.ts +65 -0
- package/src/core/compose/features/icon.ts +67 -0
- package/src/core/compose/features/index.ts +35 -0
- package/src/core/compose/features/input.ts +174 -0
- package/src/core/compose/features/lifecycle.ts +139 -0
- package/src/core/compose/features/position.ts +94 -0
- package/src/core/compose/features/ripple.ts +55 -0
- package/src/core/compose/features/size.ts +29 -0
- package/src/core/compose/features/style.ts +31 -0
- package/src/core/compose/features/text.ts +44 -0
- package/src/core/compose/features/textinput.ts +225 -0
- package/src/core/compose/features/textlabel.ts +92 -0
- package/src/core/compose/features/track.ts +84 -0
- package/src/core/compose/features/variant.ts +29 -0
- package/src/core/compose/features/withEvents.ts +137 -0
- package/src/core/compose/index.ts +54 -0
- package/src/core/compose/{pipe.js → pipe.ts} +16 -11
- package/src/core/config/component-config.ts +136 -0
- package/src/core/config.ts +211 -0
- package/src/core/dom/{attributes.js → attributes.ts} +11 -11
- package/src/core/dom/classes.ts +60 -0
- package/src/core/dom/create.ts +188 -0
- package/src/core/dom/events.ts +209 -0
- package/src/core/dom/index.ts +10 -0
- package/src/core/dom/utils.ts +97 -0
- package/src/core/index.ts +111 -0
- package/src/core/state/disabled.ts +81 -0
- package/src/core/state/emitter.ts +94 -0
- package/src/core/state/events.ts +88 -0
- package/src/core/state/index.ts +16 -0
- package/src/core/state/lifecycle.ts +131 -0
- package/src/core/state/store.ts +197 -0
- package/src/core/utils/index.ts +45 -0
- package/src/core/utils/{mobile.js → mobile.ts} +48 -24
- package/src/core/utils/object.ts +41 -0
- package/src/core/utils/validate.ts +234 -0
- package/src/{index.js → index.ts} +3 -2
- package/index.js +0 -11
- package/src/components/button/api.js +0 -54
- package/src/components/button/button.js +0 -81
- package/src/components/button/config.js +0 -10
- package/src/components/button/constants.js +0 -63
- package/src/components/button/index.js +0 -2
- package/src/components/card/card.js +0 -102
- package/src/components/card/config.js +0 -16
- package/src/components/card/index.js +0 -7
- package/src/components/card/media.js +0 -56
- package/src/components/checkbox/api.js +0 -45
- package/src/components/checkbox/checkbox.js +0 -96
- package/src/components/checkbox/index.js +0 -2
- package/src/components/container/api.js +0 -42
- package/src/components/container/container.js +0 -45
- package/src/components/container/index.js +0 -2
- package/src/components/container/styles.scss +0 -66
- package/src/components/list/index.js +0 -2
- package/src/components/list/list-item.js +0 -147
- package/src/components/list/list.js +0 -267
- package/src/components/menu/api.js +0 -117
- package/src/components/menu/constants.js +0 -42
- package/src/components/menu/features/items-manager.js +0 -375
- package/src/components/menu/features/keyboard-navigation.js +0 -129
- package/src/components/menu/features/positioning.js +0 -125
- package/src/components/menu/index.js +0 -2
- package/src/components/menu/menu-item.js +0 -41
- package/src/components/menu/menu.js +0 -54
- package/src/components/navigation/api.js +0 -43
- package/src/components/navigation/index.js +0 -2
- package/src/components/navigation/nav-item.js +0 -137
- package/src/components/navigation/navigation.js +0 -55
- package/src/components/snackbar/api.js +0 -125
- package/src/components/snackbar/features.js +0 -69
- package/src/components/snackbar/index.js +0 -2
- package/src/components/snackbar/position.js +0 -63
- package/src/components/snackbar/queue.js +0 -74
- package/src/components/snackbar/snackbar.js +0 -70
- package/src/components/switch/api.js +0 -44
- package/src/components/switch/index.js +0 -2
- package/src/components/switch/switch.js +0 -71
- package/src/components/textfield/api.js +0 -49
- package/src/components/textfield/index.js +0 -2
- package/src/components/textfield/textfield.js +0 -68
- package/src/core/build/_ripple.scss +0 -79
- package/src/core/build/constants.js +0 -51
- package/src/core/build/icon.js +0 -78
- package/src/core/build/ripple.js +0 -159
- package/src/core/build/text.js +0 -54
- package/src/core/compose/base.js +0 -8
- package/src/core/compose/component.js +0 -225
- package/src/core/compose/features/checkable.js +0 -114
- package/src/core/compose/features/disabled.js +0 -64
- package/src/core/compose/features/events.js +0 -48
- package/src/core/compose/features/icon.js +0 -33
- package/src/core/compose/features/index.js +0 -20
- package/src/core/compose/features/input.js +0 -100
- package/src/core/compose/features/lifecycle.js +0 -69
- package/src/core/compose/features/position.js +0 -60
- package/src/core/compose/features/ripple.js +0 -32
- package/src/core/compose/features/size.js +0 -9
- package/src/core/compose/features/style.js +0 -12
- package/src/core/compose/features/text.js +0 -17
- package/src/core/compose/features/textinput.js +0 -114
- package/src/core/compose/features/textlabel.js +0 -28
- package/src/core/compose/features/track.js +0 -49
- package/src/core/compose/features/variant.js +0 -9
- package/src/core/compose/features/withEvents.js +0 -67
- package/src/core/compose/index.js +0 -16
- package/src/core/config.js +0 -140
- package/src/core/dom/classes.js +0 -70
- package/src/core/dom/create.js +0 -132
- package/src/core/dom/events.js +0 -175
- package/src/core/dom/index.js +0 -5
- package/src/core/dom/utils.js +0 -22
- package/src/core/index.js +0 -23
- package/src/core/state/disabled.js +0 -51
- package/src/core/state/emitter.js +0 -63
- package/src/core/state/events.js +0 -29
- package/src/core/state/index.js +0 -6
- package/src/core/state/lifecycle.js +0 -64
- package/src/core/state/store.js +0 -112
- package/src/core/utils/index.js +0 -39
- package/src/core/utils/object.js +0 -22
- package/src/core/utils/validate.js +0 -37
- /package/src/components/checkbox/{styles.scss → _styles.scss} +0 -0
- /package/src/components/checkbox/{constants.js → constants.ts} +0 -0
- /package/src/components/list/{styles.scss → _styles.scss} +0 -0
- /package/src/components/menu/{styles.scss → _styles.scss} +0 -0
- /package/src/components/navigation/{styles.scss → _styles.scss} +0 -0
- /package/src/components/snackbar/{styles.scss → _styles.scss} +0 -0
- /package/src/components/switch/{styles.scss → _styles.scss} +0 -0
- /package/src/components/switch/{constants.js → constants.ts} +0 -0
- /package/src/components/textfield/{styles.scss → _styles.scss} +0 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// src/components/menu/features/items-manager.ts
|
|
2
|
+
import { createMenuItem } from '../menu-item';
|
|
3
|
+
import { MENU_EVENTS } from '../constants';
|
|
4
|
+
import { BaseComponent, MenuConfig, MenuItemConfig, MenuItemData } from '../types';
|
|
5
|
+
|
|
6
|
+
interface SubmenuMap {
|
|
7
|
+
[key: string]: BaseComponent;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Adds menu items management functionality to a component
|
|
12
|
+
* @param {MenuConfig} config - Menu configuration
|
|
13
|
+
* @returns {Function} Component enhancer
|
|
14
|
+
*/
|
|
15
|
+
export const withItemsManager = (config: MenuConfig) => (component: BaseComponent): BaseComponent => {
|
|
16
|
+
const submenus: Map<string, BaseComponent> = new Map();
|
|
17
|
+
const itemsMap: Map<string, MenuItemConfig> = new Map();
|
|
18
|
+
let activeSubmenu: BaseComponent | null = null;
|
|
19
|
+
let currentHoveredItem: HTMLElement | null = null;
|
|
20
|
+
const prefix = config.prefix || 'mtrl';
|
|
21
|
+
|
|
22
|
+
// Create items container
|
|
23
|
+
const list = document.createElement('ul');
|
|
24
|
+
list.className = `${prefix}-menu-list`;
|
|
25
|
+
list.setAttribute('role', 'menu');
|
|
26
|
+
component.element.appendChild(list);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Factory function for creating a submenu
|
|
30
|
+
* This will be defined after we've created a createMenu import
|
|
31
|
+
* to avoid circular dependency
|
|
32
|
+
*/
|
|
33
|
+
let createSubmenuFunction: any = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sets the submenu creation function
|
|
37
|
+
* @param {Function} createMenuFn - Function to create a menu
|
|
38
|
+
*/
|
|
39
|
+
const setCreateSubmenuFunction = (createMenuFn: any): void => {
|
|
40
|
+
createSubmenuFunction = createMenuFn;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Creates a submenu for a menu item
|
|
45
|
+
* @param {string} name - Item name
|
|
46
|
+
* @param {HTMLElement} item - Menu item element
|
|
47
|
+
* @returns {BaseComponent|null} Submenu component
|
|
48
|
+
*/
|
|
49
|
+
const createSubmenu = (name: string, item: HTMLElement): BaseComponent | null => {
|
|
50
|
+
if (!createSubmenuFunction) {
|
|
51
|
+
console.error('Submenu creation function not set. Call setCreateSubmenuFunction first.');
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const itemConfig = itemsMap.get(name);
|
|
56
|
+
if (!itemConfig?.items) return null;
|
|
57
|
+
|
|
58
|
+
const submenu = createSubmenuFunction({
|
|
59
|
+
...config,
|
|
60
|
+
items: itemConfig.items,
|
|
61
|
+
class: `${prefix}-menu--submenu`,
|
|
62
|
+
parentItem: item
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Handle submenu selection
|
|
66
|
+
submenu.on?.(MENU_EVENTS.SELECT, (detail: any) => {
|
|
67
|
+
component.emit?.(MENU_EVENTS.SELECT, {
|
|
68
|
+
name: `${name}:${detail.name}`,
|
|
69
|
+
text: detail.text,
|
|
70
|
+
path: [name, detail.name]
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return submenu;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Opens a submenu
|
|
79
|
+
* @param {string} name - Item name
|
|
80
|
+
* @param {HTMLElement} item - Menu item element
|
|
81
|
+
*/
|
|
82
|
+
const openSubmenu = (name: string, item: HTMLElement): void => {
|
|
83
|
+
// Close any open submenu that's different
|
|
84
|
+
if (activeSubmenu && submenus.get(name) !== activeSubmenu) {
|
|
85
|
+
const activeItem = list.querySelector('[aria-expanded="true"]');
|
|
86
|
+
if (activeItem && activeItem !== item) {
|
|
87
|
+
activeItem.setAttribute('aria-expanded', 'false');
|
|
88
|
+
}
|
|
89
|
+
activeSubmenu.hide?.();
|
|
90
|
+
activeSubmenu = null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// If submenu doesn't exist yet, create it
|
|
94
|
+
if (!submenus.has(name)) {
|
|
95
|
+
const submenu = createSubmenu(name, item);
|
|
96
|
+
if (submenu) {
|
|
97
|
+
submenus.set(name, submenu);
|
|
98
|
+
} else {
|
|
99
|
+
return; // No items to show
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Get submenu and show it if not already showing
|
|
104
|
+
const submenu = submenus.get(name);
|
|
105
|
+
if (submenu && (activeSubmenu !== submenu || item.getAttribute('aria-expanded') === 'false')) {
|
|
106
|
+
item.setAttribute('aria-expanded', 'true');
|
|
107
|
+
activeSubmenu = submenu;
|
|
108
|
+
|
|
109
|
+
// Position submenu relative to item
|
|
110
|
+
submenu.show?.();
|
|
111
|
+
submenu.position?.(item, {
|
|
112
|
+
align: 'right',
|
|
113
|
+
vAlign: 'top',
|
|
114
|
+
offsetX: 0,
|
|
115
|
+
offsetY: 0
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// Emit submenu open event
|
|
119
|
+
component.emit?.(MENU_EVENTS.SUBMENU_OPEN, { name });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Closes a submenu
|
|
125
|
+
* @param {string} name - Item name
|
|
126
|
+
* @param {boolean} force - Whether to force close even if submenu is hovered
|
|
127
|
+
*/
|
|
128
|
+
const closeSubmenu = (name: string, force = false): void => {
|
|
129
|
+
const submenu = submenus.get(name);
|
|
130
|
+
if (!submenu || activeSubmenu !== submenu) return;
|
|
131
|
+
|
|
132
|
+
// Don't close if submenu is currently being hovered, unless forced
|
|
133
|
+
if (!force && submenu.element && submenu.element.matches(':hover')) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const item = list.querySelector(`[data-name="${name}"][aria-expanded="true"]`);
|
|
138
|
+
if (item) {
|
|
139
|
+
item.setAttribute('aria-expanded', 'false');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
submenu.hide?.();
|
|
143
|
+
activeSubmenu = null;
|
|
144
|
+
|
|
145
|
+
// Emit submenu close event
|
|
146
|
+
component.emit?.(MENU_EVENTS.SUBMENU_CLOSE, { name });
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Handles mouseenter for submenu items
|
|
151
|
+
* @param {MouseEvent} event - Mouse event
|
|
152
|
+
*/
|
|
153
|
+
const handleMouseEnter = (event: MouseEvent): void => {
|
|
154
|
+
const target = event.target as HTMLElement;
|
|
155
|
+
const item = target.closest(`.${prefix}-menu-item--submenu`) as HTMLElement;
|
|
156
|
+
if (!item) return;
|
|
157
|
+
|
|
158
|
+
const name = item.getAttribute('data-name');
|
|
159
|
+
if (!name) return;
|
|
160
|
+
|
|
161
|
+
// Cancel any pending close timer for this item
|
|
162
|
+
if (closeTimers.has(name)) {
|
|
163
|
+
window.clearTimeout(closeTimers.get(name));
|
|
164
|
+
closeTimers.delete(name);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Small delay before opening to prevent erratic behavior when moving quickly
|
|
168
|
+
window.setTimeout(() => {
|
|
169
|
+
// Only open if we're still hovering this item (prevents multiple open attempts)
|
|
170
|
+
if (item.matches(':hover')) {
|
|
171
|
+
openSubmenu(name, item);
|
|
172
|
+
currentHoveredItem = item;
|
|
173
|
+
}
|
|
174
|
+
}, 50);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Track pending close timers
|
|
178
|
+
const closeTimers: Map<string, number> = new Map();
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Handles mouseleave for submenu items
|
|
182
|
+
* @param {MouseEvent} event - Mouse event
|
|
183
|
+
*/
|
|
184
|
+
const handleMouseLeave = (event: MouseEvent): void => {
|
|
185
|
+
const target = event.target as HTMLElement;
|
|
186
|
+
const item = target.closest(`.${prefix}-menu-item--submenu`) as HTMLElement;
|
|
187
|
+
if (!item) return;
|
|
188
|
+
|
|
189
|
+
const name = item.getAttribute('data-name');
|
|
190
|
+
if (!name) return;
|
|
191
|
+
|
|
192
|
+
// Only close if we're not entering the submenu
|
|
193
|
+
const submenu = submenus.get(name);
|
|
194
|
+
if (submenu && submenu.element) {
|
|
195
|
+
// Cancel any existing close timer for this item
|
|
196
|
+
if (closeTimers.has(name)) {
|
|
197
|
+
window.clearTimeout(closeTimers.get(name));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Set a new close timer
|
|
201
|
+
const timerId = window.setTimeout(() => {
|
|
202
|
+
if (!submenu.element.matches(':hover') &&
|
|
203
|
+
!item.matches(':hover')) {
|
|
204
|
+
closeSubmenu(name);
|
|
205
|
+
}
|
|
206
|
+
closeTimers.delete(name);
|
|
207
|
+
}, 300); // Longer delay for smoother experience
|
|
208
|
+
|
|
209
|
+
closeTimers.set(name, timerId);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
currentHoveredItem = null;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Adds hover handlers to submenu items
|
|
217
|
+
*/
|
|
218
|
+
const addHoverHandlers = (): void => {
|
|
219
|
+
// First remove any existing handlers to prevent duplicates
|
|
220
|
+
list.querySelectorAll(`.${prefix}-menu-item--submenu`).forEach(item => {
|
|
221
|
+
item.removeEventListener('mouseenter', handleMouseEnter);
|
|
222
|
+
item.removeEventListener('mouseleave', handleMouseLeave);
|
|
223
|
+
|
|
224
|
+
// Add the event listeners
|
|
225
|
+
item.addEventListener('mouseenter', handleMouseEnter);
|
|
226
|
+
item.addEventListener('mouseleave', handleMouseLeave);
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Handles click events on menu items
|
|
232
|
+
* @param {MouseEvent} event - Click event
|
|
233
|
+
*/
|
|
234
|
+
const handleItemClick = (event: MouseEvent): void => {
|
|
235
|
+
const target = event.target as HTMLElement;
|
|
236
|
+
const item = target.closest(`.${prefix}-menu-item`) as HTMLElement;
|
|
237
|
+
if (!item || item.getAttribute('aria-disabled') === 'true') return;
|
|
238
|
+
|
|
239
|
+
// For submenu items, toggle submenu
|
|
240
|
+
if (item.classList.contains(`${prefix}-menu-item--submenu`)) {
|
|
241
|
+
const name = item.getAttribute('data-name');
|
|
242
|
+
if (!name) return;
|
|
243
|
+
|
|
244
|
+
// If expanded, close it
|
|
245
|
+
if (item.getAttribute('aria-expanded') === 'true') {
|
|
246
|
+
closeSubmenu(name, true); // Force close
|
|
247
|
+
} else {
|
|
248
|
+
// Otherwise open it
|
|
249
|
+
openSubmenu(name, item);
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// For regular items, emit select event
|
|
255
|
+
const name = item.getAttribute('data-name');
|
|
256
|
+
if (name) {
|
|
257
|
+
component.emit?.(MENU_EVENTS.SELECT, { name, text: item.textContent });
|
|
258
|
+
// Hide menu after selection unless configured otherwise
|
|
259
|
+
if (!config.stayOpenOnSelect) {
|
|
260
|
+
component.hide?.();
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// Handle item clicks
|
|
266
|
+
list.addEventListener('click', handleItemClick);
|
|
267
|
+
|
|
268
|
+
// Create initial items
|
|
269
|
+
if (config.items) {
|
|
270
|
+
config.items.forEach(itemConfig => {
|
|
271
|
+
const item = createMenuItem(itemConfig, prefix);
|
|
272
|
+
list.appendChild(item);
|
|
273
|
+
|
|
274
|
+
// Store item config for later use
|
|
275
|
+
if (itemConfig.name) {
|
|
276
|
+
itemsMap.set(itemConfig.name, itemConfig);
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Add hover handlers after all items are created
|
|
282
|
+
addHoverHandlers();
|
|
283
|
+
|
|
284
|
+
// Override show method to reset state and ensure hover handlers
|
|
285
|
+
const originalShow = component.show;
|
|
286
|
+
if (originalShow) {
|
|
287
|
+
component.show = function (...args: any[]) {
|
|
288
|
+
// Reset state when showing menu
|
|
289
|
+
currentHoveredItem = null;
|
|
290
|
+
|
|
291
|
+
// Ensure all items have hover handlers
|
|
292
|
+
setTimeout(addHoverHandlers, 0);
|
|
293
|
+
|
|
294
|
+
return originalShow.apply(this, args);
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Override hide method to close all submenus
|
|
299
|
+
const originalHide = component.hide;
|
|
300
|
+
if (originalHide) {
|
|
301
|
+
component.hide = function (...args: any[]) {
|
|
302
|
+
// Close all submenus
|
|
303
|
+
if (activeSubmenu) {
|
|
304
|
+
activeSubmenu.hide?.();
|
|
305
|
+
activeSubmenu = null;
|
|
306
|
+
|
|
307
|
+
const expandedItems = list.querySelectorAll('[aria-expanded="true"]');
|
|
308
|
+
expandedItems.forEach(item => {
|
|
309
|
+
item.setAttribute('aria-expanded', 'false');
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Reset state
|
|
314
|
+
currentHoveredItem = null;
|
|
315
|
+
|
|
316
|
+
return originalHide.apply(this, args);
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Add cleanup
|
|
321
|
+
const originalDestroy = component.lifecycle?.destroy;
|
|
322
|
+
if (component.lifecycle) {
|
|
323
|
+
component.lifecycle.destroy = () => {
|
|
324
|
+
// Remove hover handlers from all items
|
|
325
|
+
list.querySelectorAll(`.${prefix}-menu-item--submenu`).forEach(item => {
|
|
326
|
+
item.removeEventListener('mouseenter', handleMouseEnter);
|
|
327
|
+
item.removeEventListener('mouseleave', handleMouseLeave);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// Remove click listener
|
|
331
|
+
list.removeEventListener('click', handleItemClick);
|
|
332
|
+
|
|
333
|
+
// Reset state
|
|
334
|
+
currentHoveredItem = null;
|
|
335
|
+
|
|
336
|
+
// Clear all pending timers
|
|
337
|
+
closeTimers.forEach(timerId => window.clearTimeout(timerId));
|
|
338
|
+
closeTimers.clear();
|
|
339
|
+
|
|
340
|
+
// Destroy all submenus
|
|
341
|
+
submenus.forEach(submenu => submenu.destroy?.());
|
|
342
|
+
submenus.clear();
|
|
343
|
+
itemsMap.clear();
|
|
344
|
+
|
|
345
|
+
if (originalDestroy) {
|
|
346
|
+
originalDestroy();
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
...component,
|
|
353
|
+
|
|
354
|
+
// Expose the setCreateSubmenuFunction method
|
|
355
|
+
setCreateSubmenuFunction,
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Closes any open submenus
|
|
359
|
+
* @returns {BaseComponent} Component instance
|
|
360
|
+
*/
|
|
361
|
+
closeSubmenus() {
|
|
362
|
+
if (activeSubmenu) {
|
|
363
|
+
activeSubmenu.hide?.();
|
|
364
|
+
activeSubmenu = null;
|
|
365
|
+
|
|
366
|
+
const expandedItems = list.querySelectorAll('[aria-expanded="true"]');
|
|
367
|
+
expandedItems.forEach(item => {
|
|
368
|
+
item.setAttribute('aria-expanded', 'false');
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return this;
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Adds an item to the menu
|
|
376
|
+
* @param {MenuItemConfig} itemConfig - Item configuration
|
|
377
|
+
* @returns {BaseComponent} Component instance
|
|
378
|
+
*/
|
|
379
|
+
addItem(itemConfig: MenuItemConfig) {
|
|
380
|
+
if (!itemConfig) return this;
|
|
381
|
+
|
|
382
|
+
const item = createMenuItem(itemConfig, prefix);
|
|
383
|
+
list.appendChild(item);
|
|
384
|
+
|
|
385
|
+
// Store item config for later use
|
|
386
|
+
if (itemConfig.name) {
|
|
387
|
+
itemsMap.set(itemConfig.name, itemConfig);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// If it's a submenu item, add hover handlers
|
|
391
|
+
if (itemConfig.items?.length) {
|
|
392
|
+
item.addEventListener('mouseenter', handleMouseEnter);
|
|
393
|
+
item.addEventListener('mouseleave', handleMouseLeave);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return this;
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Removes an item from the menu
|
|
401
|
+
* @param {string} name - Item name
|
|
402
|
+
* @returns {BaseComponent} Component instance
|
|
403
|
+
*/
|
|
404
|
+
removeItem(name: string) {
|
|
405
|
+
if (!name) return this;
|
|
406
|
+
|
|
407
|
+
// First, ensure we remove the item from our internal map
|
|
408
|
+
itemsMap.delete(name);
|
|
409
|
+
|
|
410
|
+
// Now try to remove the item from the DOM
|
|
411
|
+
const item = list.querySelector(`[data-name="${name}"]`);
|
|
412
|
+
if (item) {
|
|
413
|
+
// Remove event listeners
|
|
414
|
+
item.removeEventListener('mouseenter', handleMouseEnter);
|
|
415
|
+
item.removeEventListener('mouseleave', handleMouseLeave);
|
|
416
|
+
|
|
417
|
+
// Close any submenu associated with this item
|
|
418
|
+
if (submenus.has(name)) {
|
|
419
|
+
const submenu = submenus.get(name);
|
|
420
|
+
submenu?.destroy?.();
|
|
421
|
+
submenus.delete(name);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Remove the item from the DOM
|
|
425
|
+
item.remove();
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return this;
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Gets all registered items
|
|
433
|
+
* @returns {Map<string, MenuItemConfig>} Map of item names to configurations
|
|
434
|
+
*/
|
|
435
|
+
getItems(): Map<string, MenuItemData> {
|
|
436
|
+
const result = new Map<string, MenuItemData>();
|
|
437
|
+
|
|
438
|
+
itemsMap.forEach((config, name) => {
|
|
439
|
+
const element = list.querySelector(`[data-name="${name}"]`) as HTMLElement;
|
|
440
|
+
if (element) {
|
|
441
|
+
result.set(name, { element, config });
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
return result;
|
|
446
|
+
},
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Refreshes all hover handlers
|
|
450
|
+
* @returns {BaseComponent} Component instance
|
|
451
|
+
*/
|
|
452
|
+
refreshHoverHandlers() {
|
|
453
|
+
addHoverHandlers();
|
|
454
|
+
return this;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// src/components/menu/features/keyboard-navigation.ts
|
|
2
|
+
import { BaseComponent, MenuConfig } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adds keyboard navigation functionality to a menu component
|
|
6
|
+
* @param {MenuConfig} config - Menu configuration
|
|
7
|
+
* @returns {Function} Component enhancer
|
|
8
|
+
*/
|
|
9
|
+
export const withKeyboardNavigation = (config: MenuConfig) => (component: BaseComponent): BaseComponent => {
|
|
10
|
+
// Store the component's existing methods
|
|
11
|
+
const componentMethods = {
|
|
12
|
+
show: component.show,
|
|
13
|
+
hide: component.hide,
|
|
14
|
+
destroy: component.lifecycle?.destroy
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
let keydownHandler: ((event: KeyboardEvent) => void) | null = null;
|
|
18
|
+
const prefix = config.prefix || 'mtrl';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Handles keyboard navigation
|
|
22
|
+
* @param {KeyboardEvent} event - Keyboard event
|
|
23
|
+
*/
|
|
24
|
+
const handleKeydown = (event: KeyboardEvent) => {
|
|
25
|
+
if (!component.isVisible?.()) return;
|
|
26
|
+
|
|
27
|
+
const focusedItem = document.activeElement as HTMLElement;
|
|
28
|
+
const list = component.element.querySelector(`.${prefix}-menu-list`) as HTMLElement;
|
|
29
|
+
const isMenuItem = focusedItem.classList?.contains(`${prefix}-menu-item`);
|
|
30
|
+
const items = Array.from(
|
|
31
|
+
list.querySelectorAll(`.${prefix}-menu-item:not([aria-disabled="true"])`)
|
|
32
|
+
) as HTMLElement[];
|
|
33
|
+
|
|
34
|
+
switch (event.key) {
|
|
35
|
+
case 'ArrowDown':
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
if (!isMenuItem) {
|
|
38
|
+
items[0]?.focus();
|
|
39
|
+
} else {
|
|
40
|
+
const currentIndex = items.indexOf(focusedItem);
|
|
41
|
+
const nextItem = items[currentIndex + 1] || items[0];
|
|
42
|
+
nextItem.focus();
|
|
43
|
+
}
|
|
44
|
+
break;
|
|
45
|
+
|
|
46
|
+
case 'ArrowUp':
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
if (!isMenuItem) {
|
|
49
|
+
items[items.length - 1]?.focus();
|
|
50
|
+
} else {
|
|
51
|
+
const currentIndex = items.indexOf(focusedItem);
|
|
52
|
+
const prevItem = items[currentIndex - 1] || items[items.length - 1];
|
|
53
|
+
prevItem.focus();
|
|
54
|
+
}
|
|
55
|
+
break;
|
|
56
|
+
|
|
57
|
+
case 'ArrowRight':
|
|
58
|
+
if (isMenuItem && focusedItem.classList.contains(`${prefix}-menu-item--submenu`)) {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
const submenuEvent = new MouseEvent('click', {
|
|
61
|
+
bubbles: true,
|
|
62
|
+
cancelable: true
|
|
63
|
+
});
|
|
64
|
+
focusedItem.dispatchEvent(submenuEvent);
|
|
65
|
+
}
|
|
66
|
+
break;
|
|
67
|
+
|
|
68
|
+
case 'ArrowLeft':
|
|
69
|
+
if (config.parentItem) {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
component.hide?.();
|
|
72
|
+
(config.parentItem as HTMLElement).focus();
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case 'Enter':
|
|
77
|
+
case ' ':
|
|
78
|
+
if (isMenuItem) {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
focusedItem.click();
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Enables keyboard navigation
|
|
88
|
+
*/
|
|
89
|
+
const enableKeyboardNavigation = () => {
|
|
90
|
+
if (!keydownHandler) {
|
|
91
|
+
keydownHandler = handleKeydown;
|
|
92
|
+
document.addEventListener('keydown', keydownHandler);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Disables keyboard navigation
|
|
98
|
+
*/
|
|
99
|
+
const disableKeyboardNavigation = () => {
|
|
100
|
+
if (keydownHandler) {
|
|
101
|
+
document.removeEventListener('keydown', keydownHandler);
|
|
102
|
+
keydownHandler = null;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Enhanced component with navigation capabilities
|
|
107
|
+
const enhancedComponent: BaseComponent = {
|
|
108
|
+
...component,
|
|
109
|
+
|
|
110
|
+
show() {
|
|
111
|
+
const result = componentMethods.show?.call(this);
|
|
112
|
+
enableKeyboardNavigation();
|
|
113
|
+
return result;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
hide() {
|
|
117
|
+
disableKeyboardNavigation();
|
|
118
|
+
return componentMethods.hide?.call(this);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Add cleanup to lifecycle
|
|
123
|
+
if (component.lifecycle) {
|
|
124
|
+
component.lifecycle.destroy = () => {
|
|
125
|
+
disableKeyboardNavigation();
|
|
126
|
+
if (componentMethods.destroy) {
|
|
127
|
+
componentMethods.destroy();
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return enhancedComponent;
|
|
133
|
+
};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// src/components/menu/features/positioning.ts
|
|
2
|
+
import { BaseComponent, MenuPositionConfig, MenuPosition } from '../types';
|
|
3
|
+
import { MENU_ALIGN, MENU_VERTICAL_ALIGN } from '../constants';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Positions a menu element relative to a target element
|
|
7
|
+
* @param {HTMLElement} menuElement - Menu element to position
|
|
8
|
+
* @param {HTMLElement} target - Target element to position against
|
|
9
|
+
* @param {MenuPositionConfig} options - Positioning options
|
|
10
|
+
* @returns {MenuPosition} The final position {left, top}
|
|
11
|
+
*/
|
|
12
|
+
export const positionMenu = (
|
|
13
|
+
menuElement: HTMLElement,
|
|
14
|
+
target: HTMLElement,
|
|
15
|
+
options: MenuPositionConfig = {}
|
|
16
|
+
): MenuPosition => {
|
|
17
|
+
if (!target || !menuElement) return { left: 0, top: 0 };
|
|
18
|
+
|
|
19
|
+
// Force the menu to be visible temporarily to get accurate dimensions
|
|
20
|
+
const originalDisplay = menuElement.style.display;
|
|
21
|
+
const originalVisibility = menuElement.style.visibility;
|
|
22
|
+
const originalOpacity = menuElement.style.opacity;
|
|
23
|
+
|
|
24
|
+
menuElement.style.display = 'block';
|
|
25
|
+
menuElement.style.visibility = 'hidden';
|
|
26
|
+
menuElement.style.opacity = '0';
|
|
27
|
+
|
|
28
|
+
const targetRect = target.getBoundingClientRect();
|
|
29
|
+
const menuRect = menuElement.getBoundingClientRect();
|
|
30
|
+
|
|
31
|
+
// Restore original styles
|
|
32
|
+
menuElement.style.display = originalDisplay;
|
|
33
|
+
menuElement.style.visibility = originalVisibility;
|
|
34
|
+
menuElement.style.opacity = originalOpacity;
|
|
35
|
+
|
|
36
|
+
const {
|
|
37
|
+
align = MENU_ALIGN.LEFT,
|
|
38
|
+
vAlign = MENU_VERTICAL_ALIGN.BOTTOM,
|
|
39
|
+
offsetX = 0,
|
|
40
|
+
offsetY = 0
|
|
41
|
+
} = options;
|
|
42
|
+
|
|
43
|
+
let left = targetRect.left + offsetX;
|
|
44
|
+
let top = targetRect.bottom + offsetY;
|
|
45
|
+
|
|
46
|
+
// Handle horizontal alignment
|
|
47
|
+
if (align === MENU_ALIGN.RIGHT) {
|
|
48
|
+
left = targetRect.right - menuRect.width + offsetX;
|
|
49
|
+
} else if (align === MENU_ALIGN.CENTER) {
|
|
50
|
+
left = targetRect.left + (targetRect.width - menuRect.width) / 2 + offsetX;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Handle vertical alignment
|
|
54
|
+
if (vAlign === MENU_VERTICAL_ALIGN.TOP) {
|
|
55
|
+
top = targetRect.top - menuRect.height + offsetY;
|
|
56
|
+
} else if (vAlign === MENU_VERTICAL_ALIGN.MIDDLE) {
|
|
57
|
+
top = targetRect.top + (targetRect.height - menuRect.height) / 2 + offsetY;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Determine if this is a submenu
|
|
61
|
+
const isSubmenu = menuElement.classList.contains('mtrl-menu--submenu');
|
|
62
|
+
|
|
63
|
+
// Special positioning for submenus
|
|
64
|
+
if (isSubmenu) {
|
|
65
|
+
// By default, position to the right of the parent item
|
|
66
|
+
left = targetRect.right + 2; // Add a small gap
|
|
67
|
+
top = targetRect.top;
|
|
68
|
+
|
|
69
|
+
// Check if submenu would go off-screen to the right
|
|
70
|
+
const viewportWidth = window.innerWidth;
|
|
71
|
+
if (left + menuRect.width > viewportWidth) {
|
|
72
|
+
// Position to the left of the parent item instead
|
|
73
|
+
left = targetRect.left - menuRect.width - 2;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check if submenu would go off-screen at the bottom
|
|
77
|
+
const viewportHeight = window.innerHeight;
|
|
78
|
+
if (top + menuRect.height > viewportHeight) {
|
|
79
|
+
// Align with bottom of viewport
|
|
80
|
+
top = Math.max(0, viewportHeight - menuRect.height);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// Standard menu positioning and boundary checking
|
|
84
|
+
const viewportWidth = window.innerWidth;
|
|
85
|
+
const viewportHeight = window.innerHeight;
|
|
86
|
+
|
|
87
|
+
if (left + menuRect.width > viewportWidth) {
|
|
88
|
+
left = Math.max(0, viewportWidth - menuRect.width);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (left < 0) left = 0;
|
|
92
|
+
|
|
93
|
+
if (top + menuRect.height > viewportHeight) {
|
|
94
|
+
top = Math.max(0, targetRect.top - menuRect.height + offsetY);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (top < 0) top = 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Apply position
|
|
101
|
+
menuElement.style.left = `${left}px`;
|
|
102
|
+
menuElement.style.top = `${top}px`;
|
|
103
|
+
|
|
104
|
+
return { left, top };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Adds positioning functionality to a menu component
|
|
109
|
+
* @param {BaseComponent} component - Menu component
|
|
110
|
+
* @returns {BaseComponent} Enhanced component with positioning methods
|
|
111
|
+
*/
|
|
112
|
+
export const withPositioning = (component: BaseComponent): BaseComponent => {
|
|
113
|
+
return {
|
|
114
|
+
...component,
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Positions the menu relative to a target element
|
|
118
|
+
* @param {HTMLElement} target - Target element
|
|
119
|
+
* @param {MenuPositionConfig} options - Position options
|
|
120
|
+
* @returns {BaseComponent} Component instance
|
|
121
|
+
*/
|
|
122
|
+
position(target: HTMLElement, options?: MenuPositionConfig) {
|
|
123
|
+
positionMenu(component.element, target, options);
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
};
|