mtrl 0.3.5 → 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.
Files changed (65) hide show
  1. package/package.json +1 -1
  2. package/src/components/button/api.ts +16 -0
  3. package/src/components/button/types.ts +9 -0
  4. package/src/components/menu/api.ts +144 -267
  5. package/src/components/menu/config.ts +84 -40
  6. package/src/components/menu/features/anchor.ts +243 -0
  7. package/src/components/menu/features/controller.ts +1167 -0
  8. package/src/components/menu/features/index.ts +5 -0
  9. package/src/components/menu/features/position.ts +353 -0
  10. package/src/components/menu/index.ts +31 -63
  11. package/src/components/menu/menu.ts +72 -104
  12. package/src/components/menu/types.ts +264 -447
  13. package/src/components/select/api.ts +78 -0
  14. package/src/components/select/config.ts +76 -0
  15. package/src/components/select/features.ts +317 -0
  16. package/src/components/select/index.ts +38 -0
  17. package/src/components/select/select.ts +73 -0
  18. package/src/components/select/types.ts +355 -0
  19. package/src/components/textfield/api.ts +78 -6
  20. package/src/components/textfield/features/index.ts +17 -0
  21. package/src/components/textfield/features/leading-icon.ts +127 -0
  22. package/src/components/textfield/features/placement.ts +149 -0
  23. package/src/components/textfield/features/prefix-text.ts +107 -0
  24. package/src/components/textfield/features/suffix-text.ts +100 -0
  25. package/src/components/textfield/features/supporting-text.ts +113 -0
  26. package/src/components/textfield/features/trailing-icon.ts +108 -0
  27. package/src/components/textfield/textfield.ts +51 -15
  28. package/src/components/textfield/types.ts +70 -0
  29. package/src/core/collection/adapters/base.ts +62 -0
  30. package/src/core/collection/collection.ts +300 -0
  31. package/src/core/collection/index.ts +57 -0
  32. package/src/core/collection/list-manager.ts +333 -0
  33. package/src/core/dom/classes.ts +81 -9
  34. package/src/core/dom/create.ts +30 -19
  35. package/src/core/layout/README.md +531 -166
  36. package/src/core/layout/array.ts +3 -4
  37. package/src/core/layout/config.ts +193 -0
  38. package/src/core/layout/create.ts +1 -2
  39. package/src/core/layout/index.ts +12 -2
  40. package/src/core/layout/object.ts +2 -3
  41. package/src/core/layout/processor.ts +60 -12
  42. package/src/core/layout/result.ts +1 -2
  43. package/src/core/layout/types.ts +105 -50
  44. package/src/core/layout/utils.ts +69 -61
  45. package/src/index.ts +6 -2
  46. package/src/styles/abstract/_variables.scss +18 -0
  47. package/src/styles/components/_button.scss +21 -5
  48. package/src/styles/components/{_chip.scss → _chips.scss} +118 -4
  49. package/src/styles/components/_menu.scss +109 -18
  50. package/src/styles/components/_select.scss +265 -0
  51. package/src/styles/components/_textfield.scss +233 -42
  52. package/src/styles/main.scss +24 -23
  53. package/src/styles/utilities/_layout.scss +665 -0
  54. package/src/components/menu/features/items-manager.ts +0 -457
  55. package/src/components/menu/features/keyboard-navigation.ts +0 -133
  56. package/src/components/menu/features/positioning.ts +0 -127
  57. package/src/components/menu/features/visibility.ts +0 -230
  58. package/src/components/menu/menu-item.ts +0 -86
  59. package/src/components/menu/utils.ts +0 -67
  60. package/src/components/textfield/features.ts +0 -322
  61. package/src/core/collection/adapters/base.js +0 -26
  62. package/src/core/collection/collection.js +0 -259
  63. package/src/core/collection/list-manager.js +0 -157
  64. /package/src/core/collection/adapters/{route.js → route.ts} +0 -0
  65. /package/src/{core/build → styles/utilities}/_ripple.scss +0 -0
@@ -0,0 +1,5 @@
1
+ // src/components/menu/features/index.ts
2
+
3
+ export { default as withController } from './controller';
4
+ export { default as withAnchor } from './anchor';
5
+ export { default as withPosition } from './position';
@@ -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;
@@ -1,76 +1,44 @@
1
1
  // src/components/menu/index.ts
2
2
 
3
3
  /**
4
- * @module Menu
4
+ * Menu component module
5
5
  *
6
- * Menu component following Material Design 3 guidelines.
7
- * Menus display a list of choices on a temporary surface, appearing when users
8
- * interact with a button, action, or other control.
9
- *
10
- * The main export is the {@link default | createMenu} factory function that creates
11
- * a {@link MenuComponent} instance with the provided configuration.
12
- *
13
- * Features:
14
- * - Configurable positioning relative to other elements
15
- * - Support for nested submenus
16
- * - Keyboard navigation and accessibility
17
- * - Item selection events
18
- * - Automatic handling of outside clicks
19
- * - Support for dividers and disabled items
20
- * - Dynamic item management
21
- *
22
- * @example
23
- * ```typescript
24
- * // Create a basic menu
25
- * const menu = createMenu({
26
- * items: [
27
- * { name: 'edit', text: 'Edit' },
28
- * { name: 'duplicate', text: 'Duplicate' },
29
- * { type: 'divider' },
30
- * { name: 'delete', text: 'Delete', class: 'danger-item' }
31
- * ]
32
- * });
33
- *
34
- * // Show the menu positioned relative to a button
35
- * const button = document.getElementById('menuButton');
36
- * button.addEventListener('click', () => {
37
- * menu.position(button).show();
38
- * });
39
- *
40
- * // Handle menu selection
41
- * menu.on('select', (event) => {
42
- * console.log(`Selected: ${event.name}`);
43
- *
44
- * if (event.name === 'delete') {
45
- * // Confirm deletion
46
- * if (confirm('Are you sure?')) {
47
- * deleteItem();
48
- * }
49
- * }
50
- * });
51
- * ```
6
+ * The Menu component provides a Material Design 3 compliant dropdown menu
7
+ * system with support for nested menus, keyboard navigation, and accessibility.
52
8
  *
9
+ * @module components/menu
53
10
  * @category Components
54
11
  */
55
12
 
56
- /**
57
- * Factory function to create a new Menu component.
58
- * @see MenuComponent for the full API reference
59
- */
13
+ // Export main component factory
60
14
  export { default } from './menu';
61
15
 
16
+ // Export types and interfaces
17
+ export type {
18
+ MenuConfig,
19
+ MenuComponent,
20
+ MenuItem,
21
+ MenuDivider,
22
+ MenuContent,
23
+ MenuEvent,
24
+ MenuSelectEvent,
25
+ MenuPosition
26
+ } from './types';
27
+
62
28
  /**
63
- * Menu component types and interfaces
29
+ * Constants for menu position values - use these instead of string literals
30
+ * for better code completion and type safety.
31
+ *
32
+ * @example
33
+ * import { createMenu, MENU_POSITION } from 'mtrl';
34
+ *
35
+ * // Create a menu positioned at the bottom-right of its anchor
36
+ * const menu = createMenu({
37
+ * anchor: '#dropdown-button',
38
+ * items: [...],
39
+ * position: MENU_POSITION.BOTTOM_END
40
+ * });
64
41
  *
65
- * These types define the structure and behavior of the Menu component.
42
+ * @category Components
66
43
  */
67
- export {
68
- MenuConfig,
69
- MenuComponent,
70
- MenuItemConfig,
71
- MenuPositionConfig,
72
- MenuAlign,
73
- MenuVerticalAlign,
74
- MenuItemType,
75
- MenuEvent
76
- } from './types';
44
+ export { MENU_POSITION } from './types';