mtrl 0.2.8 → 0.2.9

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 (42) hide show
  1. package/index.ts +2 -0
  2. package/package.json +1 -1
  3. package/src/components/navigation/api.ts +131 -96
  4. package/src/components/navigation/features/controller.ts +273 -0
  5. package/src/components/navigation/features/items.ts +133 -64
  6. package/src/components/navigation/navigation.ts +17 -2
  7. package/src/components/navigation/system-types.ts +124 -0
  8. package/src/components/navigation/system.ts +776 -0
  9. package/src/components/slider/config.ts +20 -2
  10. package/src/components/slider/features/controller.ts +761 -0
  11. package/src/components/slider/features/handlers.ts +18 -15
  12. package/src/components/slider/features/index.ts +3 -2
  13. package/src/components/slider/features/range.ts +104 -0
  14. package/src/components/slider/slider.ts +34 -14
  15. package/src/components/slider/structure.ts +152 -0
  16. package/src/components/textfield/api.ts +53 -0
  17. package/src/components/textfield/features.ts +322 -0
  18. package/src/components/textfield/textfield.ts +8 -0
  19. package/src/components/textfield/types.ts +12 -3
  20. package/src/components/timepicker/clockdial.ts +1 -4
  21. package/src/core/compose/features/textinput.ts +15 -2
  22. package/src/core/composition/features/dom.ts +33 -0
  23. package/src/core/composition/features/icon.ts +131 -0
  24. package/src/core/composition/features/index.ts +11 -0
  25. package/src/core/composition/features/label.ts +156 -0
  26. package/src/core/composition/features/structure.ts +22 -0
  27. package/src/core/composition/index.ts +26 -0
  28. package/src/core/index.ts +1 -1
  29. package/src/core/structure.ts +288 -0
  30. package/src/index.ts +1 -0
  31. package/src/styles/components/_navigation-mobile.scss +244 -0
  32. package/src/styles/components/_navigation-system.scss +151 -0
  33. package/src/styles/components/_textfield.scss +250 -11
  34. package/demo/build.ts +0 -349
  35. package/demo/index.html +0 -110
  36. package/demo/main.js +0 -448
  37. package/demo/styles.css +0 -239
  38. package/server.ts +0 -86
  39. package/src/components/slider/features/slider.ts +0 -318
  40. package/src/components/slider/features/structure.ts +0 -181
  41. package/src/components/slider/features/ui.ts +0 -388
  42. package/src/components/textfield/constants.ts +0 -100
package/index.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  createList,
19
19
  createMenu,
20
20
  createNavigation,
21
+ createNavigationSystem,
21
22
  createProgress,
22
23
  createRadios,
23
24
  createSearch,
@@ -51,6 +52,7 @@ export {
51
52
  createList,
52
53
  createMenu,
53
54
  createNavigation,
55
+ createNavigationSystem,
54
56
  createProgress,
55
57
  createRadios,
56
58
  createSearch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtrl",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
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",
@@ -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;