mtrl 0.1.2 → 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.
Files changed (220) hide show
  1. package/README.md +70 -22
  2. package/index.ts +33 -0
  3. package/package.json +14 -5
  4. package/src/components/button/{styles.scss → _styles.scss} +2 -2
  5. package/src/components/button/api.ts +89 -0
  6. package/src/components/button/button.ts +50 -0
  7. package/src/components/button/config.ts +75 -0
  8. package/src/components/button/constants.ts +17 -0
  9. package/src/components/button/index.ts +4 -0
  10. package/src/components/button/types.ts +118 -0
  11. package/src/components/card/_styles.scss +359 -0
  12. package/src/components/card/actions.ts +48 -0
  13. package/src/components/card/api.ts +102 -0
  14. package/src/components/card/card.ts +41 -0
  15. package/src/components/card/config.ts +99 -0
  16. package/src/components/card/constants.ts +69 -0
  17. package/src/components/card/content.ts +48 -0
  18. package/src/components/card/features.ts +228 -0
  19. package/src/components/card/header.ts +88 -0
  20. package/src/components/card/index.ts +19 -0
  21. package/src/components/card/media.ts +52 -0
  22. package/src/components/card/types.ts +174 -0
  23. package/src/components/checkbox/api.ts +82 -0
  24. package/src/components/checkbox/checkbox.ts +75 -0
  25. package/src/components/checkbox/config.ts +90 -0
  26. package/src/components/checkbox/index.ts +4 -0
  27. package/src/components/checkbox/types.ts +146 -0
  28. package/src/components/chip/_styles.scss +372 -0
  29. package/src/components/chip/api.ts +115 -0
  30. package/src/components/chip/chip-set.ts +225 -0
  31. package/src/components/chip/chip.ts +82 -0
  32. package/src/components/chip/config.ts +92 -0
  33. package/src/components/chip/constants.ts +38 -0
  34. package/src/components/chip/index.ts +4 -0
  35. package/src/components/chip/types.ts +172 -0
  36. package/src/components/list/api.ts +72 -0
  37. package/src/components/list/config.ts +43 -0
  38. package/src/components/list/{constants.js → constants.ts} +34 -7
  39. package/src/components/list/features.ts +224 -0
  40. package/src/components/list/index.ts +14 -0
  41. package/src/components/list/list-item.ts +120 -0
  42. package/src/components/list/list.ts +37 -0
  43. package/src/components/list/types.ts +179 -0
  44. package/src/components/list/utils.ts +47 -0
  45. package/src/components/menu/api.ts +119 -0
  46. package/src/components/menu/config.ts +54 -0
  47. package/src/components/menu/constants.ts +154 -0
  48. package/src/components/menu/features/items-manager.ts +457 -0
  49. package/src/components/menu/features/keyboard-navigation.ts +133 -0
  50. package/src/components/menu/features/positioning.ts +127 -0
  51. package/src/components/menu/features/{visibility.js → visibility.ts} +66 -64
  52. package/src/components/menu/index.ts +14 -0
  53. package/src/components/menu/menu-item.ts +43 -0
  54. package/src/components/menu/menu.ts +53 -0
  55. package/src/components/menu/types.ts +178 -0
  56. package/src/components/navigation/api.ts +79 -0
  57. package/src/components/navigation/config.ts +61 -0
  58. package/src/components/navigation/{constants.js → constants.ts} +10 -10
  59. package/src/components/navigation/index.ts +14 -0
  60. package/src/components/navigation/nav-item.ts +148 -0
  61. package/src/components/navigation/navigation.ts +50 -0
  62. package/src/components/navigation/types.ts +212 -0
  63. package/src/components/progress/_styles.scss +204 -0
  64. package/src/components/progress/api.ts +179 -0
  65. package/src/components/progress/config.ts +124 -0
  66. package/src/components/progress/constants.ts +43 -0
  67. package/src/components/progress/index.ts +5 -0
  68. package/src/components/progress/progress.ts +163 -0
  69. package/src/components/progress/types.ts +102 -0
  70. package/src/components/snackbar/api.ts +162 -0
  71. package/src/components/snackbar/config.ts +62 -0
  72. package/src/components/snackbar/{constants.js → constants.ts} +21 -4
  73. package/src/components/snackbar/features.ts +76 -0
  74. package/src/components/snackbar/index.ts +4 -0
  75. package/src/components/snackbar/position.ts +71 -0
  76. package/src/components/snackbar/queue.ts +76 -0
  77. package/src/components/snackbar/snackbar.ts +60 -0
  78. package/src/components/snackbar/types.ts +58 -0
  79. package/src/components/switch/api.ts +77 -0
  80. package/src/components/switch/config.ts +74 -0
  81. package/src/components/switch/index.ts +4 -0
  82. package/src/components/switch/switch.ts +52 -0
  83. package/src/components/switch/types.ts +142 -0
  84. package/src/components/textfield/api.ts +72 -0
  85. package/src/components/textfield/config.ts +54 -0
  86. package/src/components/textfield/{constants.js → constants.ts} +38 -5
  87. package/src/components/textfield/index.ts +4 -0
  88. package/src/components/textfield/textfield.ts +50 -0
  89. package/src/components/textfield/types.ts +139 -0
  90. package/src/core/compose/base.ts +43 -0
  91. package/src/core/compose/component.ts +247 -0
  92. package/src/core/compose/features/checkable.ts +155 -0
  93. package/src/core/compose/features/disabled.ts +116 -0
  94. package/src/core/compose/features/events.ts +65 -0
  95. package/src/core/compose/features/icon.ts +67 -0
  96. package/src/core/compose/features/index.ts +35 -0
  97. package/src/core/compose/features/input.ts +174 -0
  98. package/src/core/compose/features/lifecycle.ts +139 -0
  99. package/src/core/compose/features/position.ts +94 -0
  100. package/src/core/compose/features/ripple.ts +55 -0
  101. package/src/core/compose/features/size.ts +29 -0
  102. package/src/core/compose/features/style.ts +31 -0
  103. package/src/core/compose/features/text.ts +44 -0
  104. package/src/core/compose/features/textinput.ts +225 -0
  105. package/src/core/compose/features/textlabel.ts +92 -0
  106. package/src/core/compose/features/track.ts +84 -0
  107. package/src/core/compose/features/variant.ts +29 -0
  108. package/src/core/compose/features/withEvents.ts +137 -0
  109. package/src/core/compose/index.ts +54 -0
  110. package/src/core/compose/{pipe.js → pipe.ts} +16 -11
  111. package/src/core/config/component-config.ts +136 -0
  112. package/src/core/config.ts +211 -0
  113. package/src/core/dom/{attributes.js → attributes.ts} +11 -11
  114. package/src/core/dom/classes.ts +60 -0
  115. package/src/core/dom/create.ts +188 -0
  116. package/src/core/dom/events.ts +209 -0
  117. package/src/core/dom/index.ts +10 -0
  118. package/src/core/dom/utils.ts +97 -0
  119. package/src/core/index.ts +111 -0
  120. package/src/core/state/disabled.ts +81 -0
  121. package/src/core/state/emitter.ts +94 -0
  122. package/src/core/state/events.ts +88 -0
  123. package/src/core/state/index.ts +16 -0
  124. package/src/core/state/lifecycle.ts +131 -0
  125. package/src/core/state/store.ts +197 -0
  126. package/src/core/utils/index.ts +45 -0
  127. package/src/core/utils/{mobile.js → mobile.ts} +48 -24
  128. package/src/core/utils/object.ts +41 -0
  129. package/src/core/utils/validate.ts +234 -0
  130. package/src/{index.js → index.ts} +4 -2
  131. package/index.js +0 -11
  132. package/src/components/button/api.js +0 -54
  133. package/src/components/button/button.js +0 -81
  134. package/src/components/button/config.js +0 -10
  135. package/src/components/button/constants.js +0 -63
  136. package/src/components/button/index.js +0 -2
  137. package/src/components/checkbox/api.js +0 -45
  138. package/src/components/checkbox/checkbox.js +0 -96
  139. package/src/components/checkbox/index.js +0 -2
  140. package/src/components/container/api.js +0 -42
  141. package/src/components/container/container.js +0 -45
  142. package/src/components/container/index.js +0 -2
  143. package/src/components/container/styles.scss +0 -66
  144. package/src/components/list/index.js +0 -2
  145. package/src/components/list/list-item.js +0 -147
  146. package/src/components/list/list.js +0 -267
  147. package/src/components/menu/api.js +0 -117
  148. package/src/components/menu/constants.js +0 -42
  149. package/src/components/menu/features/items-manager.js +0 -375
  150. package/src/components/menu/features/keyboard-navigation.js +0 -129
  151. package/src/components/menu/features/positioning.js +0 -125
  152. package/src/components/menu/index.js +0 -2
  153. package/src/components/menu/menu-item.js +0 -41
  154. package/src/components/menu/menu.js +0 -54
  155. package/src/components/navigation/api.js +0 -43
  156. package/src/components/navigation/index.js +0 -2
  157. package/src/components/navigation/nav-item.js +0 -137
  158. package/src/components/navigation/navigation.js +0 -55
  159. package/src/components/snackbar/api.js +0 -125
  160. package/src/components/snackbar/features.js +0 -69
  161. package/src/components/snackbar/index.js +0 -2
  162. package/src/components/snackbar/position.js +0 -63
  163. package/src/components/snackbar/queue.js +0 -74
  164. package/src/components/snackbar/snackbar.js +0 -70
  165. package/src/components/switch/api.js +0 -44
  166. package/src/components/switch/index.js +0 -2
  167. package/src/components/switch/switch.js +0 -71
  168. package/src/components/textfield/api.js +0 -49
  169. package/src/components/textfield/index.js +0 -2
  170. package/src/components/textfield/textfield.js +0 -68
  171. package/src/core/build/_ripple.scss +0 -79
  172. package/src/core/build/constants.js +0 -51
  173. package/src/core/build/icon.js +0 -78
  174. package/src/core/build/ripple.js +0 -159
  175. package/src/core/build/text.js +0 -54
  176. package/src/core/compose/base.js +0 -8
  177. package/src/core/compose/component.js +0 -225
  178. package/src/core/compose/features/checkable.js +0 -114
  179. package/src/core/compose/features/disabled.js +0 -64
  180. package/src/core/compose/features/events.js +0 -48
  181. package/src/core/compose/features/icon.js +0 -33
  182. package/src/core/compose/features/index.js +0 -20
  183. package/src/core/compose/features/input.js +0 -100
  184. package/src/core/compose/features/lifecycle.js +0 -69
  185. package/src/core/compose/features/position.js +0 -60
  186. package/src/core/compose/features/ripple.js +0 -32
  187. package/src/core/compose/features/size.js +0 -9
  188. package/src/core/compose/features/style.js +0 -12
  189. package/src/core/compose/features/text.js +0 -17
  190. package/src/core/compose/features/textinput.js +0 -114
  191. package/src/core/compose/features/textlabel.js +0 -28
  192. package/src/core/compose/features/track.js +0 -49
  193. package/src/core/compose/features/variant.js +0 -9
  194. package/src/core/compose/features/withEvents.js +0 -67
  195. package/src/core/compose/index.js +0 -16
  196. package/src/core/config.js +0 -140
  197. package/src/core/dom/classes.js +0 -70
  198. package/src/core/dom/create.js +0 -132
  199. package/src/core/dom/events.js +0 -175
  200. package/src/core/dom/index.js +0 -5
  201. package/src/core/dom/utils.js +0 -22
  202. package/src/core/index.js +0 -23
  203. package/src/core/state/disabled.js +0 -51
  204. package/src/core/state/emitter.js +0 -63
  205. package/src/core/state/events.js +0 -29
  206. package/src/core/state/index.js +0 -6
  207. package/src/core/state/lifecycle.js +0 -64
  208. package/src/core/state/store.js +0 -112
  209. package/src/core/utils/index.js +0 -39
  210. package/src/core/utils/object.js +0 -22
  211. package/src/core/utils/validate.js +0 -37
  212. /package/src/components/checkbox/{styles.scss → _styles.scss} +0 -0
  213. /package/src/components/checkbox/{constants.js → constants.ts} +0 -0
  214. /package/src/components/list/{styles.scss → _styles.scss} +0 -0
  215. /package/src/components/menu/{styles.scss → _styles.scss} +0 -0
  216. /package/src/components/navigation/{styles.scss → _styles.scss} +0 -0
  217. /package/src/components/snackbar/{styles.scss → _styles.scss} +0 -0
  218. /package/src/components/switch/{styles.scss → _styles.scss} +0 -0
  219. /package/src/components/switch/{constants.js → constants.ts} +0 -0
  220. /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
+ };