mtrl 0.3.6 → 0.3.7
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 +1 -1
- package/src/components/button/api.ts +16 -0
- package/src/components/button/types.ts +9 -0
- package/src/components/menu/api.ts +15 -13
- package/src/components/menu/config.ts +5 -5
- package/src/components/menu/features/anchor.ts +99 -15
- package/src/components/menu/features/controller.ts +418 -221
- package/src/components/menu/features/index.ts +2 -1
- package/src/components/menu/features/position.ts +353 -0
- package/src/components/menu/index.ts +5 -5
- package/src/components/menu/menu.ts +18 -60
- package/src/components/menu/types.ts +17 -16
- package/src/components/select/api.ts +78 -0
- package/src/components/select/config.ts +76 -0
- package/src/components/select/features.ts +317 -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 -1
- 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 +103 -24
- package/src/styles/components/_select.scss +265 -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
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// src/components/menu/features/position.ts
|
|
2
|
+
|
|
3
|
+
import { MenuConfig } from '../types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Menu position helper
|
|
7
|
+
* Provides functions for positioning menus and submenus
|
|
8
|
+
*/
|
|
9
|
+
export const createPositioner = (component, config: MenuConfig) => {
|
|
10
|
+
/**
|
|
11
|
+
* Positions the menu relative to its anchor
|
|
12
|
+
* Ensures the menu maintains proper spacing from viewport edges
|
|
13
|
+
* Makes sure the menu stays attached to anchor during scrolling
|
|
14
|
+
*
|
|
15
|
+
* @param menuElement - The menu element to position
|
|
16
|
+
* @param anchorElement - The element to anchor against
|
|
17
|
+
* @param preferredPosition - The preferred position
|
|
18
|
+
* @param isSubmenu - Whether this is a submenu (affects positioning logic)
|
|
19
|
+
*/
|
|
20
|
+
const positionElement = (
|
|
21
|
+
menuElement: HTMLElement,
|
|
22
|
+
anchorElement: HTMLElement,
|
|
23
|
+
preferredPosition: string,
|
|
24
|
+
isSubmenu = false
|
|
25
|
+
): void => {
|
|
26
|
+
if (!menuElement || !anchorElement) return;
|
|
27
|
+
|
|
28
|
+
// Ensure menu is positioned absolutely for proper scroll behavior
|
|
29
|
+
menuElement.style.position = 'absolute';
|
|
30
|
+
|
|
31
|
+
// Get current scroll position - critical for absolute positioning that tracks anchor
|
|
32
|
+
const scrollX = window.pageXOffset || document.documentElement.scrollLeft;
|
|
33
|
+
const scrollY = window.pageYOffset || document.documentElement.scrollTop;
|
|
34
|
+
|
|
35
|
+
// Make a copy of the menu for measurement without affecting the real menu
|
|
36
|
+
const tempMenu = menuElement.cloneNode(true) as HTMLElement;
|
|
37
|
+
|
|
38
|
+
// Make the temp menu visible but not displayed for measurement
|
|
39
|
+
tempMenu.style.visibility = 'hidden';
|
|
40
|
+
tempMenu.style.display = 'block';
|
|
41
|
+
tempMenu.style.position = 'absolute';
|
|
42
|
+
tempMenu.style.top = '0';
|
|
43
|
+
tempMenu.style.left = '0';
|
|
44
|
+
tempMenu.style.transform = 'none';
|
|
45
|
+
tempMenu.style.opacity = '0';
|
|
46
|
+
tempMenu.style.pointerEvents = 'none';
|
|
47
|
+
tempMenu.classList.add(`${component.getClass('menu--visible')}`); // Add visible class for proper dimensions
|
|
48
|
+
|
|
49
|
+
// Add it to the DOM temporarily
|
|
50
|
+
document.body.appendChild(tempMenu);
|
|
51
|
+
|
|
52
|
+
// Get measurements
|
|
53
|
+
const anchorRect = anchorElement.getBoundingClientRect();
|
|
54
|
+
const menuRect = tempMenu.getBoundingClientRect();
|
|
55
|
+
const viewportWidth = window.innerWidth;
|
|
56
|
+
const viewportHeight = window.innerHeight;
|
|
57
|
+
|
|
58
|
+
// Remove the temp element after measurements
|
|
59
|
+
document.body.removeChild(tempMenu);
|
|
60
|
+
|
|
61
|
+
// Get values needed for calculations
|
|
62
|
+
const offset = config.offset !== undefined ? config.offset : 8;
|
|
63
|
+
|
|
64
|
+
// Calculate position based on position
|
|
65
|
+
let top = 0;
|
|
66
|
+
let left = 0;
|
|
67
|
+
let calculatedPosition = preferredPosition;
|
|
68
|
+
|
|
69
|
+
// Different positioning logic for main menu vs submenu
|
|
70
|
+
if (isSubmenu) {
|
|
71
|
+
// Default position is to the right of parent
|
|
72
|
+
calculatedPosition = preferredPosition || 'right-start';
|
|
73
|
+
|
|
74
|
+
// Check if this would push the submenu out of the viewport
|
|
75
|
+
if (calculatedPosition.startsWith('right') && anchorRect.right + menuRect.width + offset > viewportWidth - 16) {
|
|
76
|
+
// Flip to the left side if it doesn't fit on the right
|
|
77
|
+
calculatedPosition = calculatedPosition.replace('right', 'left');
|
|
78
|
+
} else if (calculatedPosition.startsWith('left') && anchorRect.left - menuRect.width - offset < 16) {
|
|
79
|
+
// Flip to the right side if it doesn't fit on the left
|
|
80
|
+
calculatedPosition = calculatedPosition.replace('left', 'right');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check vertical positioning as well for submenus
|
|
84
|
+
// If submenu would extend beyond the bottom of the viewport, adjust positioning
|
|
85
|
+
if (anchorRect.top + menuRect.height > viewportHeight - 16) {
|
|
86
|
+
if (calculatedPosition === 'right-start') {
|
|
87
|
+
calculatedPosition = 'right-end';
|
|
88
|
+
} else if (calculatedPosition === 'left-start') {
|
|
89
|
+
calculatedPosition = 'left-end';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
// For main menu, follow the standard position calculation
|
|
94
|
+
// First determine correct position based on original position
|
|
95
|
+
switch (preferredPosition) {
|
|
96
|
+
case 'top-start':
|
|
97
|
+
case 'top':
|
|
98
|
+
case 'top-end':
|
|
99
|
+
// Check if enough space above
|
|
100
|
+
if (anchorRect.top < menuRect.height + offset + 16) {
|
|
101
|
+
// Not enough space above, flip to bottom
|
|
102
|
+
calculatedPosition = preferredPosition.replace('top', 'bottom');
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
|
|
106
|
+
case 'bottom-start':
|
|
107
|
+
case 'bottom':
|
|
108
|
+
case 'bottom-end':
|
|
109
|
+
// Check if enough space below
|
|
110
|
+
if (anchorRect.bottom + menuRect.height + offset + 16 > viewportHeight) {
|
|
111
|
+
// Not enough space below, check if more space above
|
|
112
|
+
if (anchorRect.top > (viewportHeight - anchorRect.bottom)) {
|
|
113
|
+
// More space above, flip to top
|
|
114
|
+
calculatedPosition = preferredPosition.replace('bottom', 'top');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
|
|
119
|
+
// Specifically handle right-start, right, left-start, and left positions
|
|
120
|
+
case 'right-start':
|
|
121
|
+
case 'right':
|
|
122
|
+
case 'left-start':
|
|
123
|
+
case 'left':
|
|
124
|
+
// Check if enough space below for these side positions
|
|
125
|
+
if (anchorRect.bottom + menuRect.height > viewportHeight - 16) {
|
|
126
|
+
// Not enough space below, shift the menu upward
|
|
127
|
+
if (preferredPosition === 'right-start') {
|
|
128
|
+
calculatedPosition = 'right-end';
|
|
129
|
+
} else if (preferredPosition === 'left-start') {
|
|
130
|
+
calculatedPosition = 'left-end';
|
|
131
|
+
} else if (preferredPosition === 'right') {
|
|
132
|
+
// For center aligned, shift up by half menu height plus some spacing
|
|
133
|
+
top = anchorRect.top - (menuRect.height - anchorRect.height) - offset;
|
|
134
|
+
} else if (preferredPosition === 'left') {
|
|
135
|
+
// For center aligned, shift up by half menu height plus some spacing
|
|
136
|
+
top = anchorRect.top - (menuRect.height - anchorRect.height) - offset;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Reset any existing position classes
|
|
144
|
+
const positionClasses = [
|
|
145
|
+
'position-top', 'position-bottom', 'position-right', 'position-left'
|
|
146
|
+
];
|
|
147
|
+
|
|
148
|
+
positionClasses.forEach(posClass => {
|
|
149
|
+
menuElement.classList.remove(`${component.getClass('menu')}--${posClass}`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Determine transform origin based on vertical position
|
|
153
|
+
// Start by checking the calculated position to determine transform origin
|
|
154
|
+
const menuAppearsAboveAnchor =
|
|
155
|
+
calculatedPosition.startsWith('top') ||
|
|
156
|
+
calculatedPosition === 'right-end' ||
|
|
157
|
+
calculatedPosition === 'left-end' ||
|
|
158
|
+
(calculatedPosition === 'right' && top < anchorRect.top) ||
|
|
159
|
+
(calculatedPosition === 'left' && top < anchorRect.top);
|
|
160
|
+
|
|
161
|
+
if (menuAppearsAboveAnchor) {
|
|
162
|
+
menuElement.classList.add(`${component.getClass('menu')}--position-top`);
|
|
163
|
+
} else if (calculatedPosition.startsWith('left')) {
|
|
164
|
+
menuElement.classList.add(`${component.getClass('menu')}--position-left`);
|
|
165
|
+
} else if (calculatedPosition.startsWith('right')) {
|
|
166
|
+
menuElement.classList.add(`${component.getClass('menu')}--position-right`);
|
|
167
|
+
} else {
|
|
168
|
+
menuElement.classList.add(`${component.getClass('menu')}--position-bottom`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Position calculation - important: getBoundingClientRect() returns values relative to viewport
|
|
172
|
+
// We need to add scroll position to get absolute position
|
|
173
|
+
switch (calculatedPosition) {
|
|
174
|
+
case 'top-start':
|
|
175
|
+
top = anchorRect.top + scrollY - menuRect.height - offset;
|
|
176
|
+
left = anchorRect.left + scrollX;
|
|
177
|
+
break;
|
|
178
|
+
case 'top':
|
|
179
|
+
top = anchorRect.top + scrollY - menuRect.height - offset;
|
|
180
|
+
left = anchorRect.left + scrollX + (anchorRect.width / 2) - (menuRect.width / 2);
|
|
181
|
+
break;
|
|
182
|
+
case 'top-end':
|
|
183
|
+
top = anchorRect.top + scrollY - menuRect.height - offset;
|
|
184
|
+
left = anchorRect.right + scrollX - menuRect.width;
|
|
185
|
+
break;
|
|
186
|
+
case 'right-start':
|
|
187
|
+
top = anchorRect.top + scrollY;
|
|
188
|
+
left = anchorRect.right + scrollX + offset;
|
|
189
|
+
break;
|
|
190
|
+
case 'right':
|
|
191
|
+
// Custom top position might be set above; only set if not already defined
|
|
192
|
+
if (top === 0) {
|
|
193
|
+
top = anchorRect.top + scrollY + (anchorRect.height / 2) - (menuRect.height / 2);
|
|
194
|
+
} else {
|
|
195
|
+
top += scrollY;
|
|
196
|
+
}
|
|
197
|
+
left = anchorRect.right + scrollX + offset;
|
|
198
|
+
break;
|
|
199
|
+
case 'right-end':
|
|
200
|
+
top = anchorRect.bottom + scrollY - menuRect.height;
|
|
201
|
+
left = anchorRect.right + scrollX + offset;
|
|
202
|
+
break;
|
|
203
|
+
case 'bottom-start':
|
|
204
|
+
top = anchorRect.bottom + scrollY + offset;
|
|
205
|
+
left = anchorRect.left + scrollX;
|
|
206
|
+
break;
|
|
207
|
+
case 'bottom':
|
|
208
|
+
top = anchorRect.bottom + scrollY + offset;
|
|
209
|
+
left = anchorRect.left + scrollX + (anchorRect.width / 2) - (menuRect.width / 2);
|
|
210
|
+
break;
|
|
211
|
+
case 'bottom-end':
|
|
212
|
+
top = anchorRect.bottom + scrollY + offset;
|
|
213
|
+
left = anchorRect.right + scrollX - menuRect.width;
|
|
214
|
+
break;
|
|
215
|
+
case 'left-start':
|
|
216
|
+
top = anchorRect.top + scrollY;
|
|
217
|
+
left = anchorRect.left + scrollX - menuRect.width - offset;
|
|
218
|
+
break;
|
|
219
|
+
case 'left':
|
|
220
|
+
// Custom top position might be set above; only set if not already defined
|
|
221
|
+
if (top === 0) {
|
|
222
|
+
top = anchorRect.top + scrollY + (anchorRect.height / 2) - (menuRect.height / 2);
|
|
223
|
+
} else {
|
|
224
|
+
top += scrollY;
|
|
225
|
+
}
|
|
226
|
+
left = anchorRect.left + scrollX - menuRect.width - offset;
|
|
227
|
+
break;
|
|
228
|
+
case 'left-end':
|
|
229
|
+
top = anchorRect.bottom + scrollY - menuRect.height;
|
|
230
|
+
left = anchorRect.left + scrollX - menuRect.width - offset;
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Ensure the menu has proper spacing from viewport edges
|
|
235
|
+
|
|
236
|
+
// Top edge spacing - ensure the menu doesn't go above the viewport + padding
|
|
237
|
+
const minTopSpacing = 16; // Minimum distance from top of viewport
|
|
238
|
+
if (top - scrollY < minTopSpacing) {
|
|
239
|
+
top = minTopSpacing + scrollY;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Bottom edge spacing - ensure the menu doesn't go below the viewport - padding
|
|
243
|
+
const viewportBottomMargin = 16; // Minimum space from bottom of viewport
|
|
244
|
+
const bottomEdge = (top - scrollY) + menuRect.height;
|
|
245
|
+
|
|
246
|
+
if (bottomEdge > viewportHeight - viewportBottomMargin) {
|
|
247
|
+
// Option 1: We could adjust the top position
|
|
248
|
+
// top = scrollY + viewportHeight - viewportBottomMargin - menuRect.height;
|
|
249
|
+
|
|
250
|
+
// Option 2: Instead of moving the menu, adjust its height to fit (better UX)
|
|
251
|
+
const availableHeight = viewportHeight - (top - scrollY) - viewportBottomMargin;
|
|
252
|
+
|
|
253
|
+
// Set a minimum height to prevent tiny menus
|
|
254
|
+
const minMenuHeight = Math.min(menuRect.height, 100);
|
|
255
|
+
const newMaxHeight = Math.max(availableHeight, minMenuHeight);
|
|
256
|
+
|
|
257
|
+
// Update maxHeight to fit within viewport
|
|
258
|
+
menuElement.style.maxHeight = `${newMaxHeight}px`;
|
|
259
|
+
|
|
260
|
+
// If user has explicitly set a maxHeight, respect it if smaller
|
|
261
|
+
if (config.maxHeight) {
|
|
262
|
+
const configMaxHeight = parseInt(config.maxHeight, 10);
|
|
263
|
+
if (!isNaN(configMaxHeight) && configMaxHeight < parseInt(menuElement.style.maxHeight || '0', 10)) {
|
|
264
|
+
menuElement.style.maxHeight = config.maxHeight;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
} else {
|
|
268
|
+
// If there's plenty of space, use the config's maxHeight (if provided)
|
|
269
|
+
if (config.maxHeight) {
|
|
270
|
+
menuElement.style.maxHeight = config.maxHeight;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// For 'width: 100%' configuration, match the anchor width
|
|
275
|
+
if (config.width === '100%' && !isSubmenu) {
|
|
276
|
+
menuElement.style.width = `${anchorRect.width}px`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Apply final positions, ensuring menu stays within viewport
|
|
280
|
+
// The position is absolute, not fixed, so it must account for scroll
|
|
281
|
+
menuElement.style.top = `${Math.max(minTopSpacing + scrollY, top)}px`;
|
|
282
|
+
menuElement.style.left = `${Math.max(16 + scrollX, left)}px`;
|
|
283
|
+
|
|
284
|
+
// Make sure menu doesn't extend past right edge
|
|
285
|
+
if ((left - scrollX) + menuRect.width > viewportWidth - 16) {
|
|
286
|
+
// If we're going past the right edge, set right with fixed distance from edge
|
|
287
|
+
menuElement.style.left = 'auto';
|
|
288
|
+
menuElement.style.right = '16px';
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Positions the main menu relative to its anchor
|
|
294
|
+
*/
|
|
295
|
+
const positionMenu = (anchorElement: HTMLElement): void => {
|
|
296
|
+
if (!anchorElement || !component.element) return;
|
|
297
|
+
positionElement(component.element, anchorElement, config.position, false);
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Positions a submenu relative to its parent menu item
|
|
302
|
+
* For deeply nested submenus, alternates side placement (right/left)
|
|
303
|
+
* @param submenuElement - The submenu element to position
|
|
304
|
+
* @param parentItemElement - The parent menu item element
|
|
305
|
+
* @param level - Nesting level for calculating position
|
|
306
|
+
*/
|
|
307
|
+
const positionSubmenu = (
|
|
308
|
+
submenuElement: HTMLElement,
|
|
309
|
+
parentItemElement: HTMLElement,
|
|
310
|
+
level: number = 1
|
|
311
|
+
): void => {
|
|
312
|
+
if (!submenuElement || !parentItemElement) return;
|
|
313
|
+
|
|
314
|
+
// Alternate between right and left positioning for deeper nesting levels
|
|
315
|
+
// This helps prevent menus from cascading off the screen
|
|
316
|
+
const prefPosition = level % 2 === 1 ? 'right-start' : 'left-start';
|
|
317
|
+
|
|
318
|
+
// Use higher z-index for deeper nested menus to ensure proper layering
|
|
319
|
+
submenuElement.style.zIndex = `${1000 + (level * 10)}`;
|
|
320
|
+
|
|
321
|
+
positionElement(submenuElement, parentItemElement, prefPosition, true);
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
return {
|
|
325
|
+
positionMenu,
|
|
326
|
+
positionSubmenu,
|
|
327
|
+
positionElement
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Adds positioning functionality to the menu component
|
|
333
|
+
*
|
|
334
|
+
* @param config - Menu configuration options
|
|
335
|
+
* @returns Component enhancer with positioning functionality
|
|
336
|
+
*/
|
|
337
|
+
export const withPosition = (config: MenuConfig) => component => {
|
|
338
|
+
// Do nothing if no element
|
|
339
|
+
if (!component.element) {
|
|
340
|
+
return component;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Create the positioner
|
|
344
|
+
const positioner = createPositioner(component, config);
|
|
345
|
+
|
|
346
|
+
// Return enhanced component
|
|
347
|
+
return {
|
|
348
|
+
...component,
|
|
349
|
+
position: positioner
|
|
350
|
+
};
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
export default withPosition;
|
|
@@ -22,23 +22,23 @@ export type {
|
|
|
22
22
|
MenuContent,
|
|
23
23
|
MenuEvent,
|
|
24
24
|
MenuSelectEvent,
|
|
25
|
-
|
|
25
|
+
MenuPosition
|
|
26
26
|
} from './types';
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Constants for menu
|
|
29
|
+
* Constants for menu position values - use these instead of string literals
|
|
30
30
|
* for better code completion and type safety.
|
|
31
31
|
*
|
|
32
32
|
* @example
|
|
33
|
-
* import { createMenu,
|
|
33
|
+
* import { createMenu, MENU_POSITION } from 'mtrl';
|
|
34
34
|
*
|
|
35
35
|
* // Create a menu positioned at the bottom-right of its anchor
|
|
36
36
|
* const menu = createMenu({
|
|
37
37
|
* anchor: '#dropdown-button',
|
|
38
38
|
* items: [...],
|
|
39
|
-
*
|
|
39
|
+
* position: MENU_POSITION.BOTTOM_END
|
|
40
40
|
* });
|
|
41
41
|
*
|
|
42
42
|
* @category Components
|
|
43
43
|
*/
|
|
44
|
-
export {
|
|
44
|
+
export { MENU_POSITION } from './types';
|
|
@@ -4,6 +4,7 @@ import { pipe } from '../../core/compose';
|
|
|
4
4
|
import { createBase, withElement } from '../../core/compose/component';
|
|
5
5
|
import { withEvents, withLifecycle } from '../../core/compose/features';
|
|
6
6
|
import { withController, withAnchor } from './features';
|
|
7
|
+
import { withPosition } from './features/position';
|
|
7
8
|
import { withAPI } from './api';
|
|
8
9
|
import { MenuConfig, MenuComponent } from './types';
|
|
9
10
|
import { createBaseConfig, getElementConfig, getApiConfig } from './config';
|
|
@@ -18,6 +19,9 @@ import { createBaseConfig, getElementConfig, getApiConfig } from './config';
|
|
|
18
19
|
* Menus are built using a functional composition pattern, applying various
|
|
19
20
|
* features through the pipe function for a modular architecture.
|
|
20
21
|
*
|
|
22
|
+
* The menu element is not added to the DOM until it's opened, and it's removed
|
|
23
|
+
* from the DOM when closed, following best practices for dropdown menus.
|
|
24
|
+
*
|
|
21
25
|
* @param {MenuConfig} config - Configuration options for the menu
|
|
22
26
|
* This must include an anchor element or selector, and an array of menu items.
|
|
23
27
|
* See {@link MenuConfig} for all available options.
|
|
@@ -44,14 +48,14 @@ import { createBaseConfig, getElementConfig, getApiConfig } from './config';
|
|
|
44
48
|
* ]
|
|
45
49
|
* });
|
|
46
50
|
*
|
|
47
|
-
* // Add the menu to the document
|
|
48
|
-
* document.body.appendChild(menu.element);
|
|
49
|
-
*
|
|
50
51
|
* // Add event listener for item selection
|
|
51
52
|
* menu.on('select', (event) => {
|
|
52
53
|
* console.log('Selected item:', event.itemId);
|
|
53
54
|
* });
|
|
54
55
|
*
|
|
56
|
+
* // Menu will be added to the DOM when opened and removed when closed
|
|
57
|
+
* menuButton.addEventListener('click', () => menu.toggle());
|
|
58
|
+
*
|
|
55
59
|
* @example
|
|
56
60
|
* // Create a menu with nested submenus
|
|
57
61
|
* const menu = createMenu({
|
|
@@ -70,68 +74,21 @@ import { createBaseConfig, getElementConfig, getApiConfig } from './config';
|
|
|
70
74
|
* { type: 'divider' },
|
|
71
75
|
* { id: 'delete', text: 'Delete', icon: '<svg>...</svg>' }
|
|
72
76
|
* ],
|
|
73
|
-
*
|
|
77
|
+
* position: 'bottom-end'
|
|
74
78
|
* });
|
|
75
79
|
*
|
|
76
80
|
* @example
|
|
77
|
-
* //
|
|
78
|
-
* const
|
|
79
|
-
* anchor:
|
|
80
|
-
* items:
|
|
81
|
+
* // Specify a custom position for the menu
|
|
82
|
+
* const filterMenu = createMenu({
|
|
83
|
+
* anchor: filterButton,
|
|
84
|
+
* items: filterOptions,
|
|
85
|
+
* position: MENU_POSITION.TOP_START,
|
|
86
|
+
* width: '240px',
|
|
87
|
+
* maxHeight: '400px'
|
|
81
88
|
* });
|
|
82
89
|
*
|
|
83
|
-
* //
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* // Later, close the menu
|
|
87
|
-
* userMenu.close();
|
|
88
|
-
*/
|
|
89
|
-
/**
|
|
90
|
-
* Creates a new Menu component with the specified configuration.
|
|
91
|
-
*
|
|
92
|
-
* The Menu component implements the Material Design 3 menu specifications,
|
|
93
|
-
* providing a flexible dropdown menu system with support for nested menus,
|
|
94
|
-
* keyboard navigation, and ARIA accessibility.
|
|
95
|
-
*
|
|
96
|
-
* Menus are built using a functional composition pattern, applying various
|
|
97
|
-
* features through the pipe function for a modular architecture.
|
|
98
|
-
*
|
|
99
|
-
* The menu element is not added to the DOM until it's opened, and it's removed
|
|
100
|
-
* from the DOM when closed, following best practices for dropdown menus.
|
|
101
|
-
*
|
|
102
|
-
* @param {MenuConfig} config - Configuration options for the menu
|
|
103
|
-
* This must include an anchor element or selector, and an array of menu items.
|
|
104
|
-
* See {@link MenuConfig} for all available options.
|
|
105
|
-
*
|
|
106
|
-
* @returns {MenuComponent} A fully configured menu component instance with
|
|
107
|
-
* all requested features applied. The returned component has methods for
|
|
108
|
-
* menu manipulation, event handling, and lifecycle management.
|
|
109
|
-
*
|
|
110
|
-
* @throws {Error} Throws an error if menu creation fails or if required
|
|
111
|
-
* configuration (like anchor) is missing.
|
|
112
|
-
*
|
|
113
|
-
* @category Components
|
|
114
|
-
*
|
|
115
|
-
* @example
|
|
116
|
-
* // Create a simple menu anchored to a button
|
|
117
|
-
* const menuButton = document.getElementById('menu-button');
|
|
118
|
-
* const menu = createMenu({
|
|
119
|
-
* anchor: menuButton,
|
|
120
|
-
* items: [
|
|
121
|
-
* { id: 'item1', text: 'Option 1' },
|
|
122
|
-
* { id: 'item2', text: 'Option 2' },
|
|
123
|
-
* { type: 'divider' },
|
|
124
|
-
* { id: 'item3', text: 'Option 3' }
|
|
125
|
-
* ]
|
|
126
|
-
* });
|
|
127
|
-
*
|
|
128
|
-
* // Add event listener for item selection
|
|
129
|
-
* menu.on('select', (event) => {
|
|
130
|
-
* console.log('Selected item:', event.itemId);
|
|
131
|
-
* });
|
|
132
|
-
*
|
|
133
|
-
* // Menu will be added to the DOM when opened and removed when closed
|
|
134
|
-
* menuButton.addEventListener('click', () => menu.toggle());
|
|
90
|
+
* // Update the menu's position programmatically
|
|
91
|
+
* filterMenu.setPosition(MENU_POSITION.BOTTOM_END);
|
|
135
92
|
*/
|
|
136
93
|
const createMenu = (config: MenuConfig): MenuComponent => {
|
|
137
94
|
try {
|
|
@@ -143,6 +100,7 @@ const createMenu = (config: MenuConfig): MenuComponent => {
|
|
|
143
100
|
createBase, // Base component
|
|
144
101
|
withEvents(), // Event handling
|
|
145
102
|
withElement(getElementConfig(baseConfig)), // DOM element
|
|
103
|
+
withPosition(baseConfig), // Position management
|
|
146
104
|
withController(baseConfig), // Menu controller
|
|
147
105
|
withAnchor(baseConfig), // Anchor management
|
|
148
106
|
withLifecycle(), // Lifecycle management
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// src/components/menu/types.ts
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Menu
|
|
4
|
+
* Menu position options
|
|
5
5
|
* Controls where the menu will appear relative to its anchor element
|
|
6
6
|
*
|
|
7
7
|
* @category Components
|
|
8
8
|
*/
|
|
9
|
-
export const
|
|
9
|
+
export const MENU_POSITION = {
|
|
10
10
|
/** Places menu below the anchor, aligned to left edge */
|
|
11
11
|
BOTTOM_START: 'bottom-start',
|
|
12
12
|
/** Places menu below the anchor, centered */
|
|
@@ -34,11 +34,11 @@ export const MENU_PLACEMENT = {
|
|
|
34
34
|
} as const;
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
|
-
*
|
|
37
|
+
* Position options for the menu
|
|
38
38
|
*
|
|
39
39
|
* @category Components
|
|
40
40
|
*/
|
|
41
|
-
export type
|
|
41
|
+
export type MenuPosition = typeof MENU_POSITION[keyof typeof MENU_POSITION];
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Configuration interface for a menu item
|
|
@@ -126,9 +126,9 @@ export type MenuContent = MenuItem | MenuDivider;
|
|
|
126
126
|
export interface MenuConfig {
|
|
127
127
|
/**
|
|
128
128
|
* Element to which the menu will be anchored
|
|
129
|
-
* Can be an HTML element
|
|
129
|
+
* Can be an HTML element, a CSS selector string, or a component with an element property
|
|
130
130
|
*/
|
|
131
|
-
anchor: HTMLElement | string;
|
|
131
|
+
anchor: HTMLElement | string | { element: HTMLElement };
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
134
|
* Array of menu items and dividers to display
|
|
@@ -136,10 +136,10 @@ export interface MenuConfig {
|
|
|
136
136
|
items: MenuContent[];
|
|
137
137
|
|
|
138
138
|
/**
|
|
139
|
-
*
|
|
139
|
+
* Position of the menu relative to the anchor
|
|
140
140
|
* @default 'bottom-start'
|
|
141
141
|
*/
|
|
142
|
-
|
|
142
|
+
position?: MenuPosition;
|
|
143
143
|
|
|
144
144
|
/**
|
|
145
145
|
* Whether the menu should close when an item is clicked
|
|
@@ -184,7 +184,7 @@ export interface MenuConfig {
|
|
|
184
184
|
offset?: number;
|
|
185
185
|
|
|
186
186
|
/**
|
|
187
|
-
* Whether the menu should automatically flip
|
|
187
|
+
* Whether the menu should automatically flip position to stay in viewport
|
|
188
188
|
* @default true
|
|
189
189
|
*/
|
|
190
190
|
autoFlip?: boolean;
|
|
@@ -280,9 +280,10 @@ export interface MenuComponent {
|
|
|
280
280
|
/**
|
|
281
281
|
* Opens the menu
|
|
282
282
|
* @param event - Optional event that triggered the open
|
|
283
|
+
* @param interactionType - The type of interaction that triggered the open ('mouse' or 'keyboard')
|
|
283
284
|
* @returns The menu component for chaining
|
|
284
285
|
*/
|
|
285
|
-
open: (event?: Event) => MenuComponent;
|
|
286
|
+
open: (event?: Event, interactionType?: 'mouse' | 'keyboard') => MenuComponent;
|
|
286
287
|
|
|
287
288
|
/**
|
|
288
289
|
* Closes the menu
|
|
@@ -331,17 +332,17 @@ export interface MenuComponent {
|
|
|
331
332
|
getAnchor: () => HTMLElement;
|
|
332
333
|
|
|
333
334
|
/**
|
|
334
|
-
* Updates the menu's
|
|
335
|
-
* @param
|
|
335
|
+
* Updates the menu's position
|
|
336
|
+
* @param position - New position value
|
|
336
337
|
* @returns The menu component for chaining
|
|
337
338
|
*/
|
|
338
|
-
|
|
339
|
+
setPosition: (position: MenuPosition) => MenuComponent;
|
|
339
340
|
|
|
340
341
|
/**
|
|
341
|
-
* Gets the current menu
|
|
342
|
-
* @returns Current
|
|
342
|
+
* Gets the current menu position
|
|
343
|
+
* @returns Current position
|
|
343
344
|
*/
|
|
344
|
-
|
|
345
|
+
getPosition: () => MenuPosition;
|
|
345
346
|
|
|
346
347
|
/**
|
|
347
348
|
* Adds an event listener to the menu
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// src/components/select/api.ts
|
|
2
|
+
import { SelectComponent, ApiOptions, SelectOption } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Enhances a select component with API methods
|
|
6
|
+
* @param options - API configuration options
|
|
7
|
+
* @returns Higher-order function that adds API methods to component
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export const withAPI = (options: ApiOptions) =>
|
|
11
|
+
(component: any): SelectComponent => ({
|
|
12
|
+
...component,
|
|
13
|
+
element: component.element,
|
|
14
|
+
textfield: component.textfield,
|
|
15
|
+
menu: component.menu,
|
|
16
|
+
|
|
17
|
+
getValue: options.select.getValue,
|
|
18
|
+
|
|
19
|
+
setValue(value: string): SelectComponent {
|
|
20
|
+
options.select.setValue(value);
|
|
21
|
+
return this;
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
getText: options.select.getText,
|
|
25
|
+
|
|
26
|
+
getSelectedOption: options.select.getSelectedOption,
|
|
27
|
+
|
|
28
|
+
getOptions: options.select.getOptions,
|
|
29
|
+
|
|
30
|
+
setOptions(options: SelectOption[]): SelectComponent {
|
|
31
|
+
options.select.setOptions(options);
|
|
32
|
+
return this;
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
open(interactionType: 'mouse' | 'keyboard' = 'mouse'): SelectComponent {
|
|
36
|
+
options.select.open(undefined, interactionType);
|
|
37
|
+
return this;
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
close(): SelectComponent {
|
|
41
|
+
options.select.close();
|
|
42
|
+
return this;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
isOpen: options.select.isOpen,
|
|
46
|
+
|
|
47
|
+
on(event, handler) {
|
|
48
|
+
if (options.events?.on) {
|
|
49
|
+
options.events.on(event, handler);
|
|
50
|
+
} else if (component.on) {
|
|
51
|
+
component.on(event, handler);
|
|
52
|
+
}
|
|
53
|
+
return this;
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
off(event, handler) {
|
|
57
|
+
if (options.events?.off) {
|
|
58
|
+
options.events.off(event, handler);
|
|
59
|
+
} else if (component.off) {
|
|
60
|
+
component.off(event, handler);
|
|
61
|
+
}
|
|
62
|
+
return this;
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
enable(): SelectComponent {
|
|
66
|
+
options.disabled.enable();
|
|
67
|
+
return this;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
disable(): SelectComponent {
|
|
71
|
+
options.disabled.disable();
|
|
72
|
+
return this;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
destroy() {
|
|
76
|
+
options.lifecycle.destroy();
|
|
77
|
+
}
|
|
78
|
+
});
|