mtrl 0.2.8 → 0.3.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 (64) hide show
  1. package/index.ts +4 -0
  2. package/package.json +1 -1
  3. package/src/components/button/button.ts +34 -5
  4. package/src/components/navigation/api.ts +131 -96
  5. package/src/components/navigation/features/controller.ts +273 -0
  6. package/src/components/navigation/features/items.ts +133 -64
  7. package/src/components/navigation/navigation.ts +17 -2
  8. package/src/components/navigation/system/core.ts +302 -0
  9. package/src/components/navigation/system/events.ts +240 -0
  10. package/src/components/navigation/system/index.ts +184 -0
  11. package/src/components/navigation/system/mobile.ts +278 -0
  12. package/src/components/navigation/system/state.ts +77 -0
  13. package/src/components/navigation/system/types.ts +364 -0
  14. package/src/components/slider/config.ts +20 -2
  15. package/src/components/slider/features/controller.ts +737 -0
  16. package/src/components/slider/features/handlers.ts +18 -16
  17. package/src/components/slider/features/index.ts +3 -2
  18. package/src/components/slider/features/range.ts +104 -0
  19. package/src/components/slider/schema.ts +141 -0
  20. package/src/components/slider/slider.ts +34 -13
  21. package/src/components/switch/api.ts +16 -0
  22. package/src/components/switch/config.ts +1 -18
  23. package/src/components/switch/features.ts +198 -0
  24. package/src/components/switch/index.ts +1 -0
  25. package/src/components/switch/switch.ts +3 -3
  26. package/src/components/switch/types.ts +14 -2
  27. package/src/components/textfield/api.ts +53 -0
  28. package/src/components/textfield/features.ts +322 -0
  29. package/src/components/textfield/textfield.ts +8 -0
  30. package/src/components/textfield/types.ts +12 -3
  31. package/src/components/timepicker/clockdial.ts +1 -4
  32. package/src/core/compose/features/textinput.ts +15 -2
  33. package/src/core/composition/features/dom.ts +45 -0
  34. package/src/core/composition/features/icon.ts +131 -0
  35. package/src/core/composition/features/index.ts +12 -0
  36. package/src/core/composition/features/label.ts +155 -0
  37. package/src/core/composition/features/layout.ts +47 -0
  38. package/src/core/composition/index.ts +26 -0
  39. package/src/core/index.ts +1 -1
  40. package/src/core/layout/README.md +350 -0
  41. package/src/core/layout/array.ts +181 -0
  42. package/src/core/layout/create.ts +55 -0
  43. package/src/core/layout/index.ts +26 -0
  44. package/src/core/layout/object.ts +124 -0
  45. package/src/core/layout/processor.ts +58 -0
  46. package/src/core/layout/result.ts +85 -0
  47. package/src/core/layout/types.ts +125 -0
  48. package/src/core/layout/utils.ts +136 -0
  49. package/src/index.ts +1 -0
  50. package/src/styles/abstract/_variables.scss +28 -0
  51. package/src/styles/components/_navigation-mobile.scss +244 -0
  52. package/src/styles/components/_navigation-system.scss +151 -0
  53. package/src/styles/components/_switch.scss +133 -69
  54. package/src/styles/components/_textfield.scss +259 -27
  55. package/demo/build.ts +0 -349
  56. package/demo/index.html +0 -110
  57. package/demo/main.js +0 -448
  58. package/demo/styles.css +0 -239
  59. package/server.ts +0 -86
  60. package/src/components/slider/features/slider.ts +0 -318
  61. package/src/components/slider/features/structure.ts +0 -181
  62. package/src/components/slider/features/ui.ts +0 -388
  63. package/src/components/textfield/constants.ts +0 -100
  64. package/src/core/layout/index.js +0 -95
package/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // index.js
2
2
  import {
3
3
  createLayout,
4
+ createStructure,
4
5
  createElement,
5
6
  createBadge,
6
7
  createBottomAppBar,
@@ -18,6 +19,7 @@ import {
18
19
  createList,
19
20
  createMenu,
20
21
  createNavigation,
22
+ createNavigationSystem,
21
23
  createProgress,
22
24
  createRadios,
23
25
  createSearch,
@@ -34,6 +36,7 @@ import {
34
36
 
35
37
  export {
36
38
  createLayout,
39
+ createStructure,
37
40
  createElement,
38
41
  createBadge,
39
42
  createBottomAppBar,
@@ -51,6 +54,7 @@ export {
51
54
  createList,
52
55
  createMenu,
53
56
  createNavigation,
57
+ createNavigationSystem,
54
58
  createProgress,
55
59
  createRadios,
56
60
  createSearch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtrl",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "A functional TypeScript/JavaScript component library with composable architecture based on Material Design 3",
5
5
  "author": "floor",
6
6
  "license": "MIT License",
@@ -16,13 +16,43 @@ import { ButtonConfig } from './types';
16
16
  import { createBaseConfig, getElementConfig, getApiConfig } from './config';
17
17
 
18
18
  /**
19
- * Creates a new Button component
20
- * @param {ButtonConfig} config - Button configuration object
21
- * @returns {ButtonComponent} Button component instance
19
+ * Creates a new Button component with the specified configuration.
20
+ *
21
+ * The Button component is created using a functional composition pattern,
22
+ * applying various features through the pipe function. This approach allows
23
+ * for flexible and modular component construction.
24
+ *
25
+ * @param {ButtonConfig} config - Configuration options for the button
26
+ * This can include text content, icon options, variant styling, disabled state,
27
+ * and other button properties. See {@link ButtonConfig} for available options.
28
+ *
29
+ * @returns {ButtonComponent} A fully configured button component instance with
30
+ * all requested features applied. The returned component has methods for
31
+ * manipulation, event handling, and lifecycle management.
32
+ *
33
+ * @throws {Error} Throws an error if button creation fails for any reason
34
+ *
35
+ * @example
36
+ * // Create a simple text button
37
+ * const textButton = createButton({ text: 'Click me' });
38
+ *
39
+ * @example
40
+ * // Create a primary button with an icon
41
+ * const primaryButton = createButton({
42
+ * text: 'Submit',
43
+ * variant: 'primary',
44
+ * icon: 'send'
45
+ * });
46
+ *
47
+ * @example
48
+ * // Create a disabled button
49
+ * const disabledButton = createButton({
50
+ * text: 'Not available',
51
+ * disabled: true
52
+ * });
22
53
  */
23
54
  const createButton = (config: ButtonConfig = {}) => {
24
55
  const baseConfig = createBaseConfig(config);
25
-
26
56
  try {
27
57
  const button = pipe(
28
58
  createBase,
@@ -36,7 +66,6 @@ const createButton = (config: ButtonConfig = {}) => {
36
66
  withLifecycle(),
37
67
  comp => withAPI(getApiConfig(comp))(comp)
38
68
  )(baseConfig);
39
-
40
69
  return button;
41
70
  } catch (error) {
42
71
  console.error('Button creation error:', error);
@@ -1,107 +1,142 @@
1
- // src/components/button/api.ts
2
- import { ButtonComponent } from './types';
3
-
4
- interface ApiOptions {
5
- disabled: {
6
- enable: () => void;
7
- disable: () => void;
8
- };
9
- lifecycle: {
10
- destroy: () => void;
11
- };
12
- }
13
-
14
- interface ComponentWithElements {
15
- element: HTMLElement;
16
- text: {
17
- setText: (content: string) => any;
18
- getText: () => string;
19
- getElement: () => HTMLElement | null;
20
- };
21
- icon: {
22
- setIcon: (html: string) => any;
23
- getIcon: () => string;
24
- getElement: () => HTMLElement | null;
25
- };
26
- getClass: (name: string) => string;
27
- }
1
+ // src/components/navigation/api.ts
2
+ import { NavigationComponent, NavItemConfig, NavItemData, BaseComponent, ApiOptions } from './types';
28
3
 
29
4
  /**
30
- * Enhances a button component with API methods
5
+ * Enhances a component with navigation-specific API methods
31
6
  * @param {ApiOptions} options - API configuration options
32
7
  * @returns {Function} Higher-order function that adds API methods to component
33
- * @internal This is an internal utility for the Button component
34
8
  */
35
- export const withAPI = ({ disabled, lifecycle }: ApiOptions) =>
36
- (component: ComponentWithElements): ButtonComponent => ({
37
- ...component as any,
38
- element: component.element as HTMLButtonElement,
9
+ export const withAPI = (options: ApiOptions) =>
10
+ (component: BaseComponent): NavigationComponent => {
39
11
 
40
- getValue: () => component.element.value,
41
-
42
- setValue(value: string) {
43
- component.element.value = value;
44
- return this;
45
- },
46
-
47
- enable() {
48
- disabled.enable();
49
- return this;
50
- },
51
-
52
- disable() {
53
- disabled.disable();
54
- return this;
55
- },
56
-
57
- setText(content: string) {
58
- component.text.setText(content);
59
- this.updateCircularStyle();
12
+ const navComponent = {
13
+ ...component,
14
+ element: component.element,
15
+ items: component.items || new Map(),
16
+
17
+ // Basic item operations
18
+ addItem(config: NavItemConfig): NavigationComponent {
19
+ if (typeof component.addItem === 'function') {
20
+ component.addItem(config);
21
+ }
22
+ return this;
23
+ },
24
+
25
+ removeItem(id: string): NavigationComponent {
26
+ if (typeof component.removeItem === 'function') {
27
+ component.removeItem(id);
28
+ }
29
+ return this;
30
+ },
31
+
32
+ getItem(id: string): NavItemData | undefined {
33
+ if (typeof component.getItem === 'function') {
34
+ return component.getItem(id);
35
+ }
36
+ return this.items.get(id);
37
+ },
38
+
39
+ getAllItems(): NavItemData[] {
40
+ if (typeof component.getAllItems === 'function') {
41
+ return component.getAllItems();
42
+ }
43
+ return Array.from(this.items.values());
44
+ },
45
+
46
+ // Path and active item management
47
+ getActive(): NavItemData | null {
48
+ if (typeof component.getActive === 'function') {
49
+ return component.getActive();
50
+ }
51
+ return null;
52
+ },
53
+
54
+ getItemPath(id: string): string[] {
55
+ if (typeof component.getItemPath === 'function') {
56
+ return component.getItemPath(id);
57
+ }
58
+ return [];
59
+ },
60
60
 
61
- // If removing text from a button with an icon, ensure it has an accessible name
62
- if (!content && component.icon.getElement()) {
63
- if (!this.element.getAttribute('aria-label')) {
64
- const className = this.element.className.split(' ')
65
- .find(cls => !cls.startsWith(`${component.getClass('button')}`));
66
-
67
- if (className) {
68
- this.element.setAttribute('aria-label', className);
61
+ setActive(id: string, silent): NavigationComponent {
62
+ // Use the controller if available for consistent handling
63
+ if (typeof component.handleItemClick === 'function') {
64
+ component.handleItemClick(id);
65
+ } else if (typeof component.setActive === 'function') {
66
+ component.setActive(id);
67
+ } else {
68
+ // Fallback if setActive is not available
69
+ const item = this.items.get(id);
70
+ if (item && item.element) {
71
+ // Emit a change event to propagate the state change
72
+ if (component.emit) {
73
+ component.emit('change', {
74
+ id,
75
+ item,
76
+ source: 'api'
77
+ });
78
+ }
69
79
  }
70
80
  }
71
- }
81
+ return this;
82
+ },
72
83
 
73
- return this;
74
- },
75
-
76
- getText() {
77
- return component.text.getText();
78
- },
79
-
80
- setIcon(icon: string) {
81
- component.icon.setIcon(icon);
82
- this.updateCircularStyle();
83
- return this;
84
- },
85
-
86
- getIcon() {
87
- return component.icon.getIcon();
88
- },
89
-
90
- setAriaLabel(label: string) {
91
- component.element.setAttribute('aria-label', label);
92
- return this;
93
- },
94
-
95
- destroy() {
96
- lifecycle.destroy();
97
- },
98
-
99
- updateCircularStyle() {
100
- const hasText = component.text.getText();
101
- if (!hasText && component.icon.getElement()) {
102
- component.element.classList.add(`${component.getClass('button')}--circular`);
103
- } else {
104
- component.element.classList.remove(`${component.getClass('button')}--circular`);
84
+ // Navigation state management
85
+ enable(): NavigationComponent {
86
+ if (options.disabled.enable) {
87
+ options.disabled.enable();
88
+ }
89
+ return this;
90
+ },
91
+
92
+ disable(): NavigationComponent {
93
+ if (options.disabled.disable) {
94
+ options.disabled.disable();
95
+ }
96
+ return this;
97
+ },
98
+
99
+ expand(): NavigationComponent {
100
+ this.element.classList.remove(`${this.element.className.split(' ')[0]}--hidden`);
101
+ this.element.setAttribute('aria-hidden', 'false');
102
+
103
+ if (component.emit) {
104
+ component.emit('expanded', { source: 'api' });
105
+ }
106
+ return this;
107
+ },
108
+
109
+ collapse(): NavigationComponent {
110
+ this.element.classList.add(`${this.element.className.split(' ')[0]}--hidden`);
111
+ this.element.setAttribute('aria-hidden', 'true');
112
+
113
+ if (component.emit) {
114
+ component.emit('collapsed', { source: 'api' });
115
+ }
116
+ return this;
117
+ },
118
+
119
+ isExpanded(): boolean {
120
+ return !this.element.classList.contains(`${this.element.className.split(' ')[0]}--hidden`);
121
+ },
122
+
123
+ toggle(): NavigationComponent {
124
+ return this.isExpanded() ? this.collapse() : this.expand();
125
+ },
126
+ // on: component.on,
127
+ // off: component.off,
128
+ // emit: component.emit,
129
+
130
+ // Destruction
131
+ destroy(): void {
132
+ if (options.lifecycle.destroy) {
133
+ options.lifecycle.destroy();
134
+ }
105
135
  }
106
- }
107
- });
136
+ };
137
+
138
+ // Return the enhanced component
139
+ return navComponent;
140
+ };
141
+
142
+ export default withAPI;
@@ -0,0 +1,273 @@
1
+ // src/components/navigation/features/controller.ts
2
+ import { BaseComponent, NavClass, NavItemData } from '../types';
3
+
4
+ /**
5
+ * Configuration interface for controller feature
6
+ */
7
+ interface ControllerConfig {
8
+ /** Component prefix for class names */
9
+ prefix?: string;
10
+
11
+ /** Debug mode flag */
12
+ debug?: boolean;
13
+
14
+ /** Component name */
15
+ componentName?: string;
16
+
17
+ /** Additional configuration options */
18
+ [key: string]: any;
19
+ }
20
+
21
+ /**
22
+ * Enhanced component with controller capabilities
23
+ */
24
+ interface ControllerComponent extends BaseComponent {
25
+ /** Handler method for item click events */
26
+ handleItemClick: (id: string) => void;
27
+ }
28
+
29
+ /**
30
+ * Adds event delegation controller to a navigation component
31
+ * This centralizes event handling for all navigation items
32
+ *
33
+ * @param {ControllerConfig} config - Controller configuration
34
+ * @returns {Function} Component enhancer function
35
+ */
36
+ export const withController = (config: ControllerConfig) => (component: BaseComponent): ControllerComponent => {
37
+ const prefix = config.prefix || 'mtrl';
38
+
39
+ /**
40
+ * Updates the active state for an item
41
+ * @param {HTMLElement} item - Item element to activate
42
+ * @param {boolean} active - Whether to make active or inactive
43
+ */
44
+ const updateItemState = (item: HTMLElement, active: boolean): void => {
45
+ // Safety check - ensure item exists
46
+ if (!item) return;
47
+
48
+ // Determine the correct active attribute based on role
49
+ const role = item.getAttribute('role');
50
+
51
+ if (active) {
52
+ item.classList.add(`${prefix}-${NavClass.ITEM}--active`);
53
+
54
+ // Set appropriate attribute based on role
55
+ if (role === 'tab') {
56
+ item.setAttribute('aria-selected', 'true');
57
+ item.setAttribute('tabindex', '0');
58
+ } else if (!item.getAttribute('aria-haspopup')) {
59
+ // Use aria-current for navigation items that aren't expandable
60
+ item.setAttribute('aria-current', 'page');
61
+ }
62
+ } else {
63
+ item.classList.remove(`${prefix}-${NavClass.ITEM}--active`);
64
+
65
+ // Remove appropriate attribute based on role
66
+ if (role === 'tab') {
67
+ item.setAttribute('aria-selected', 'false');
68
+ item.setAttribute('tabindex', '-1');
69
+ } else if (item.hasAttribute('aria-current')) {
70
+ item.removeAttribute('aria-current');
71
+ }
72
+ }
73
+ };
74
+
75
+ /**
76
+ * Handle expandable item toggle
77
+ * @param {HTMLElement} item - Expandable item element
78
+ * @returns {boolean} Whether the item was expandable and handled
79
+ */
80
+ const handleExpandableItem = (item: HTMLElement): boolean => {
81
+ const isExpandable = item.getAttribute('aria-expanded') !== null;
82
+ if (!isExpandable) return false;
83
+
84
+ // Toggle expanded state
85
+ const isExpanded = item.getAttribute('aria-expanded') === 'true';
86
+ item.setAttribute('aria-expanded', (!isExpanded).toString());
87
+
88
+ // Find and toggle nested container - use flexible selectors
89
+ const container = item.closest(`.${prefix}-${NavClass.ITEM_CONTAINER}, .mtrl-nav-item-container`);
90
+ if (container) {
91
+ const nestedContainer = container.querySelector(
92
+ `.${prefix}-${NavClass.NESTED_CONTAINER}, .mtrl-nav-nested-container`
93
+ );
94
+ if (nestedContainer) {
95
+ nestedContainer.hidden = isExpanded;
96
+
97
+ // Also toggle expand icon rotation if present
98
+ const expandIcon = item.querySelector(
99
+ `.${prefix}-${NavClass.EXPAND_ICON}, .mtrl-nav-expand-icon`
100
+ );
101
+ if (expandIcon && expandIcon.style) {
102
+ expandIcon.style.transform = isExpanded ? '' : 'rotate(90deg)';
103
+ }
104
+ }
105
+ }
106
+
107
+ // For expandable items, we still emit a change event
108
+ const id = item.dataset.id;
109
+ if (id && component.emit) {
110
+ component.emit('expandToggle', {
111
+ id,
112
+ expanded: !isExpanded
113
+ });
114
+ }
115
+
116
+ return true;
117
+ };
118
+
119
+ // Create the enhanced component with handleItemClick method
120
+ const enhancedComponent: ControllerComponent = {
121
+ ...component,
122
+
123
+ /**
124
+ * Handler method for item click events
125
+ * @param {string} id - ID of the clicked item
126
+ */
127
+ handleItemClick(id: string) {
128
+ if (!component.items) {
129
+ return;
130
+ }
131
+
132
+ const itemData = component.items.get(id);
133
+ if (!itemData) {
134
+ return;
135
+ }
136
+
137
+ // Find the currently active item by DOM query instead of relying on getActive
138
+ const activeElement = component.element.querySelector(`.${prefix}-${NavClass.ITEM}--active, .mtrl-nav-item--active`);
139
+
140
+ // Check if this item is already active - prevent infinite loops
141
+ // if (activeElement && activeElement === itemData.element) {
142
+ // return;
143
+ // }
144
+
145
+ // Deactivate previous active item if found
146
+ if (activeElement) {
147
+ updateItemState(activeElement as HTMLElement, false);
148
+ }
149
+
150
+ // Make sure itemData.element exists before updating
151
+ if (itemData.element) {
152
+ updateItemState(itemData.element, true);
153
+ }
154
+
155
+ // Emit change event
156
+ if (component.emit) {
157
+ // Get the path to the item (for nested items)
158
+ const path = component.getItemPath ? component.getItemPath(id) : [];
159
+
160
+ component.emit('change', {
161
+ id,
162
+ item: itemData,
163
+ previousItem: activeElement ? {
164
+ element: activeElement as HTMLElement,
165
+ config: { id: activeElement.dataset.id }
166
+ } : null,
167
+ path,
168
+ source: 'userAction'
169
+ });
170
+ }
171
+ }
172
+ };
173
+
174
+ // Set up click event delegation for all navigation items
175
+ component.element.addEventListener('click', (event: Event) => {
176
+ const target = event.target as HTMLElement;
177
+
178
+ // Use more flexible selectors that match actual DOM structure
179
+ const item = target.closest(`.${prefix}-${NavClass.ITEM}, .mtrl-nav-item`) as HTMLElement;
180
+
181
+ if (!item) {
182
+ // Fallback to elements with data-id attribute
183
+ const itemByDataId = target.closest('[data-id]') as HTMLElement;
184
+ if (!itemByDataId) {
185
+ return;
186
+ }
187
+ }
188
+
189
+ // Use the found item or fallback
190
+ const navItem = item || target.closest('[data-id]') as HTMLElement;
191
+
192
+ if (navItem.hasAttribute('disabled') || navItem.getAttribute('aria-disabled') === 'true') {
193
+ return;
194
+ }
195
+
196
+ // Get the ID from the data attribute
197
+ const id = navItem.dataset.id;
198
+ if (!id) {
199
+ return;
200
+ }
201
+
202
+ // Handle expandable items first
203
+ if (handleExpandableItem(navItem)) {
204
+ return;
205
+ }
206
+
207
+ // Let the enhanced component handle normal item activation
208
+ enhancedComponent.handleItemClick(id);
209
+ });
210
+
211
+ // Add keyboard support for navigation
212
+ component.element.addEventListener('keydown', (event: KeyboardEvent) => {
213
+ // Only handle specific keys
214
+ if (!['Enter', ' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) {
215
+ return;
216
+ }
217
+
218
+ const isVertical = ['rail', 'drawer'].includes(component.variant || '');
219
+ const isHorizontal = ['bar'].includes(component.variant || '');
220
+
221
+ // Handle Enter/Space for activation
222
+ if (event.key === 'Enter' || event.key === ' ') {
223
+ const item = document.activeElement as HTMLElement;
224
+ if (item && item.classList.contains(`${prefix}-${NavClass.ITEM}`)) {
225
+ event.preventDefault();
226
+
227
+ const id = item.dataset.id;
228
+ if (id) {
229
+ item.click(); // Trigger a click event for the item
230
+ }
231
+ }
232
+ return;
233
+ }
234
+
235
+ // Get all focusable navigation items - use flexible selector
236
+ const items = Array.from(
237
+ component.element.querySelectorAll(
238
+ `.${prefix}-${NavClass.ITEM}:not([disabled]):not([aria-disabled="true"]),
239
+ .mtrl-nav-item:not([disabled]):not([aria-disabled="true"])`
240
+ )
241
+ ) as HTMLElement[];
242
+
243
+ if (items.length === 0) return;
244
+
245
+ // Find the currently focused item
246
+ const focusedItem = document.activeElement as HTMLElement;
247
+ const focusedIndex = items.indexOf(focusedItem);
248
+
249
+ // Handle navigation keys
250
+ let newIndex = -1;
251
+
252
+ if ((isVertical && (event.key === 'ArrowDown' || event.key === 'ArrowRight')) ||
253
+ (isHorizontal && event.key === 'ArrowRight')) {
254
+ newIndex = focusedIndex < 0 ? 0 : (focusedIndex + 1) % items.length;
255
+ } else if ((isVertical && (event.key === 'ArrowUp' || event.key === 'ArrowLeft')) ||
256
+ (isHorizontal && event.key === 'ArrowLeft')) {
257
+ newIndex = focusedIndex < 0 ? items.length - 1 : (focusedIndex - 1 + items.length) % items.length;
258
+ } else if (event.key === 'Home') {
259
+ newIndex = 0;
260
+ } else if (event.key === 'End') {
261
+ newIndex = items.length - 1;
262
+ }
263
+
264
+ if (newIndex >= 0) {
265
+ event.preventDefault();
266
+ items[newIndex].focus();
267
+ }
268
+ });
269
+
270
+ return enhancedComponent;
271
+ };
272
+
273
+ export default withController;