mtrl 0.3.6 → 0.3.8

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 (45) hide show
  1. package/package.json +2 -2
  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 +61 -22
  5. package/src/components/menu/config.ts +10 -8
  6. package/src/components/menu/features/anchor.ts +254 -19
  7. package/src/components/menu/features/controller.ts +724 -271
  8. package/src/components/menu/features/index.ts +11 -2
  9. package/src/components/menu/features/position.ts +353 -0
  10. package/src/components/menu/index.ts +5 -5
  11. package/src/components/menu/menu.ts +21 -61
  12. package/src/components/menu/types.ts +30 -16
  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 +331 -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/index.ts +4 -45
  34. package/src/styles/abstract/_variables.scss +18 -0
  35. package/src/styles/components/_button.scss +21 -5
  36. package/src/styles/components/{_chip.scss → _chips.scss} +118 -4
  37. package/src/styles/components/_menu.scss +97 -24
  38. package/src/styles/components/_select.scss +272 -0
  39. package/src/styles/components/_textfield.scss +233 -42
  40. package/src/styles/main.scss +2 -1
  41. package/src/components/textfield/features.ts +0 -322
  42. package/src/core/collection/adapters/base.js +0 -26
  43. package/src/core/collection/collection.js +0 -259
  44. package/src/core/collection/list-manager.js +0 -157
  45. /package/src/core/collection/adapters/{route.js → route.ts} +0 -0
@@ -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
+ });
@@ -0,0 +1,76 @@
1
+ // src/components/select/config.ts
2
+
3
+ import {
4
+ createComponentConfig,
5
+ createElementConfig
6
+ } from '../../core/config/component-config';
7
+ import { SelectConfig, BaseComponent, ApiOptions } from './types';
8
+
9
+ /**
10
+ * Default configuration for the Select component
11
+ */
12
+ export const defaultConfig: SelectConfig = {
13
+ options: [],
14
+ variant: 'filled',
15
+ placement: 'bottom-start'
16
+ };
17
+
18
+ /**
19
+ * Creates the base configuration for Select component
20
+ * @param {SelectConfig} config - User provided configuration
21
+ * @returns {SelectConfig} Complete configuration with defaults applied
22
+ */
23
+ export const createBaseConfig = (config: SelectConfig = {}): SelectConfig =>
24
+ createComponentConfig(defaultConfig, config, 'select') as SelectConfig;
25
+
26
+ /**
27
+ * Creates API configuration for the Select component
28
+ * @param {BaseComponent} comp - Component with select features
29
+ * @returns {ApiOptions} API configuration object
30
+ */
31
+ export const getApiConfig = (comp: BaseComponent): ApiOptions => ({
32
+ select: {
33
+ getValue: comp.select?.getValue || (() => null),
34
+ setValue: comp.select?.setValue || (() => comp),
35
+ getText: comp.select?.getText || (() => ''),
36
+ getSelectedOption: comp.select?.getSelectedOption || (() => null),
37
+ getOptions: comp.select?.getOptions || (() => []),
38
+ setOptions: comp.select?.setOptions || (() => comp),
39
+ open: comp.select?.open || (() => comp),
40
+ close: comp.select?.close || (() => comp),
41
+ isOpen: comp.select?.isOpen || (() => false)
42
+ },
43
+ events: {
44
+ on: comp.on || (() => comp),
45
+ off: comp.off || (() => comp)
46
+ },
47
+ disabled: {
48
+ enable: () => {
49
+ if (comp.textfield?.enable) {
50
+ comp.textfield.enable();
51
+ }
52
+ return comp;
53
+ },
54
+ disable: () => {
55
+ if (comp.textfield?.disable) {
56
+ comp.textfield.disable();
57
+ }
58
+ return comp;
59
+ }
60
+ },
61
+ lifecycle: {
62
+ destroy: () => {
63
+ if (comp.textfield?.destroy) {
64
+ comp.textfield.destroy();
65
+ }
66
+ if (comp.menu?.destroy) {
67
+ comp.menu.destroy();
68
+ }
69
+ if (comp.lifecycle?.destroy) {
70
+ comp.lifecycle.destroy();
71
+ }
72
+ }
73
+ }
74
+ });
75
+
76
+ export default defaultConfig;
@@ -0,0 +1,331 @@
1
+ // src/components/select/features.ts
2
+ import createTextfield from '../textfield';
3
+ import createMenu from '../menu';
4
+ import { SelectOption, SelectConfig, BaseComponent } from './types';
5
+
6
+ /**
7
+ * Creates a textfield for the select component
8
+ * @param config - Select configuration
9
+ * @returns Function that enhances a component with textfield functionality
10
+ */
11
+ export const withTextfield = (config: SelectConfig) =>
12
+ (component: BaseComponent): BaseComponent => {
13
+ // Get option text from value if provided
14
+ let initialText = '';
15
+ if (config.value) {
16
+ const option = (config.options || []).find(opt => opt.id === config.value);
17
+ if (option) {
18
+ initialText = option.text;
19
+ }
20
+ }
21
+
22
+ // Create dropdown icon for the textfield
23
+ const dropdownIcon = '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="currentColor"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M7 10l5 5 5-5H7z"/></svg>';
24
+
25
+ // Create textfield component
26
+ const textfield = createTextfield({
27
+ label: config.label,
28
+ variant: config.variant || 'filled',
29
+ value: initialText,
30
+ name: config.name,
31
+ disabled: config.disabled,
32
+ required: config.required,
33
+ supportingText: config.supportingText,
34
+ error: config.error,
35
+ trailingIcon: dropdownIcon,
36
+ readonly: true // Make readonly since selection happens via menu
37
+ });
38
+
39
+ // Add select-specific class
40
+ textfield.element.classList.add(`${config.prefix || 'mtrl'}-select`);
41
+
42
+ return {
43
+ ...component,
44
+ element: textfield.element,
45
+ textfield
46
+ };
47
+ };
48
+
49
+ /**
50
+ * Recursively processes select options to create menu items
51
+ * Ensures all items have proper data structure
52
+ * @param options The options to process
53
+ * @returns Properly structured menu items
54
+ */
55
+ const processMenuItems = (options) => {
56
+ return options.map(option => {
57
+ if ('type' in option && option.type === 'divider') {
58
+ return option; // Just pass dividers through
59
+ }
60
+
61
+ // Create a basic menu item
62
+ const menuItem = {
63
+ id: option.id,
64
+ text: option.text,
65
+ icon: option.icon,
66
+ disabled: option.disabled,
67
+ hasSubmenu: false,
68
+ data: option
69
+ };
70
+
71
+ // If this option has a submenu, process those items recursively
72
+ if (option.hasSubmenu && Array.isArray(option.submenu)) {
73
+ menuItem.hasSubmenu = true;
74
+ menuItem.submenu = processMenuItems(option.submenu);
75
+ }
76
+
77
+ return menuItem;
78
+ });
79
+ };
80
+
81
+ /**
82
+ * Creates a menu for the select component with improved keyboard navigation
83
+ * @param config - Select configuration
84
+ * @returns Function that enhances a component with menu functionality
85
+ */
86
+ export const withMenu = (config: SelectConfig) =>
87
+ (component: BaseComponent): BaseComponent => {
88
+ if (!component.textfield) {
89
+ console.warn('Cannot add menu: textfield not found');
90
+ return component;
91
+ }
92
+
93
+ // Initialize state
94
+ const state = {
95
+ options: config.options || [],
96
+ selectedOption: null as SelectOption
97
+ };
98
+
99
+ // Find initial selected option
100
+ if (config.value) {
101
+ state.selectedOption = state.options.find(opt => opt.id === config.value);
102
+ }
103
+
104
+ // Convert options to menu items with proper recursive processing
105
+ const menuItems = processMenuItems(state.options);
106
+
107
+ // Create menu component
108
+ const menu = createMenu({
109
+ anchor: component.textfield.element,
110
+ items: menuItems,
111
+ placement: config.placement || 'bottom-start',
112
+ width: '100%', // Match width of textfield
113
+ closeOnSelect: true,
114
+ closeOnClickOutside: true,
115
+ closeOnEscape: true,
116
+ offset: 0 // Set offset to 0 to eliminate gap between textfield and menu
117
+ });
118
+
119
+ // This flag helps us know if we need to restore focus after menu closes
120
+ let needsFocusRestore = false;
121
+
122
+ // Handle menu selection
123
+ menu.on('select', (event) => {
124
+ // Safely access data properties with proper type checking
125
+ if (!event.item || event.item.hasSubmenu) {
126
+ return; // Skip processing for submenu items
127
+ }
128
+
129
+ // Safely extract the option data and validate it
130
+ const option = event.item.data;
131
+ if (!option || !('id' in option) || !('text' in option)) {
132
+ console.warn('Invalid menu selection: missing required data properties');
133
+ return;
134
+ }
135
+
136
+ state.selectedOption = option;
137
+
138
+ // Update textfield
139
+ component.textfield.setValue(option.text);
140
+
141
+ // Update the selected state in the menu
142
+ menu.setSelected(option.id);
143
+
144
+ // Set flag to restore focus after menu closes
145
+ needsFocusRestore = true;
146
+
147
+ // Emit change event
148
+ if (component.emit) {
149
+ component.emit('change', {
150
+ select: component,
151
+ value: option.id,
152
+ text: option.text,
153
+ option,
154
+ originalEvent: event.originalEvent,
155
+ preventDefault: () => { event.defaultPrevented = true; },
156
+ defaultPrevented: false
157
+ });
158
+ }
159
+ });
160
+
161
+ // Add keyboard event listener for textfield
162
+ component.textfield.element.addEventListener('keydown', (e) => {
163
+ if (component.textfield.input.disabled) return;
164
+
165
+ // Handle keyboard-based open
166
+ if ((e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') && !menu.isOpen()) {
167
+ e.preventDefault();
168
+
169
+ // Set flag to restore focus
170
+ needsFocusRestore = true;
171
+
172
+ // Open menu with keyboard interaction
173
+ menu.open(e, 'keyboard');
174
+
175
+ // Ensure textfield keeps focus
176
+ setTimeout(() => {
177
+ // Focus on the textfield to ensure we can capture further keyboard events
178
+ component.textfield.input.focus();
179
+ }, 50);
180
+
181
+ // Emit open event
182
+ if (component.emit) {
183
+ component.emit('open', {
184
+ select: component,
185
+ originalEvent: e,
186
+ preventDefault: () => {},
187
+ defaultPrevented: false
188
+ });
189
+ }
190
+ } else if (e.key === 'Escape' && menu.isOpen()) {
191
+ e.preventDefault();
192
+ needsFocusRestore = true;
193
+ menu.close(e);
194
+ }
195
+ });
196
+
197
+ // Update textfield styling when menu opens/closes
198
+ menu.on('open', () => {
199
+ // Add open class to the select component
200
+ component.textfield.element.classList.add(`${config.prefix || 'mtrl'}-select--open`);
201
+
202
+ // Add focused class to the textfield
203
+ const PREFIX = config.prefix || 'mtrl';
204
+ component.textfield.element.classList.add(`${PREFIX}-textfield--focused`);
205
+
206
+ // If using the filled variant, we need to add focus styles to ensure the label changes color
207
+ if (component.textfield.element.classList.contains(`${PREFIX}-textfield--filled`)) {
208
+ component.textfield.element.classList.add(`${PREFIX}-textfield--filled-focused`);
209
+ }
210
+ });
211
+
212
+ menu.on('close', (event) => {
213
+ // Remove open class from the select component
214
+ component.textfield.element.classList.remove(`${config.prefix || 'mtrl'}-select--open`);
215
+
216
+ // Restore focus to textfield if needed
217
+ if (needsFocusRestore) {
218
+ // Small delay to ensure menu is fully closed
219
+ setTimeout(() => {
220
+ // Explicitly focus the textfield input
221
+ component.textfield.input.focus();
222
+
223
+ // Keep focused styling
224
+ const PREFIX = config.prefix || 'mtrl';
225
+ component.textfield.element.classList.add(`${PREFIX}-textfield--focused`);
226
+
227
+ if (component.textfield.element.classList.contains(`${PREFIX}-textfield--filled`)) {
228
+ component.textfield.element.classList.add(`${PREFIX}-textfield--filled-focused`);
229
+ }
230
+
231
+ // Reset the flag
232
+ needsFocusRestore = false;
233
+ }, 50);
234
+ } else {
235
+ // Only remove focus styling if we're not restoring focus
236
+ const PREFIX = config.prefix || 'mtrl';
237
+ component.textfield.element.classList.remove(`${PREFIX}-textfield--focused`);
238
+
239
+ if (component.textfield.element.classList.contains(`${PREFIX}-textfield--filled`)) {
240
+ component.textfield.element.classList.remove(`${PREFIX}-textfield--filled-focused`);
241
+ }
242
+ }
243
+
244
+ // Emit close event
245
+ if (component.emit) {
246
+ component.emit('close', {
247
+ select: component,
248
+ originalEvent: event.originalEvent,
249
+ preventDefault: () => {},
250
+ defaultPrevented: false
251
+ });
252
+ }
253
+ });
254
+
255
+ // Handle special case for showing selected item in menu
256
+ const markSelectedMenuItem = () => {
257
+ if (!state.selectedOption) return;
258
+
259
+ // Use menu's setSelected method to update the selected state
260
+ menu.setSelected(state.selectedOption.id);
261
+ };
262
+
263
+ // Mark selected item when menu opens
264
+ menu.on('open', () => {
265
+ setTimeout(markSelectedMenuItem, 50); // Small delay to ensure DOM is ready
266
+ });
267
+
268
+ // Expose select API
269
+ return {
270
+ ...component,
271
+ menu,
272
+
273
+ // Select controller
274
+ select: {
275
+ getValue: () => state.selectedOption?.id || null,
276
+
277
+ setValue: (value) => {
278
+ const option = state.options.find(opt => 'id' in opt && opt.id === value);
279
+ if (option && 'text' in option) {
280
+ state.selectedOption = option;
281
+ component.textfield.setValue(option.text);
282
+
283
+ // Update selected state using menu's setSelected
284
+ menu.setSelected(option.id);
285
+ }
286
+ return component;
287
+ },
288
+
289
+ getText: () => state.selectedOption?.text || '',
290
+
291
+ getSelectedOption: () => state.selectedOption,
292
+
293
+ getOptions: () => [...state.options],
294
+
295
+ setOptions: (options) => {
296
+ state.options = options;
297
+
298
+ // Process options to menu items with proper handling for submenus
299
+ const menuItems = processMenuItems(options);
300
+ menu.setItems(menuItems);
301
+
302
+ // If previously selected option is no longer available, clear selection
303
+ if (state.selectedOption && !options.find(opt =>
304
+ 'id' in opt && opt.id === state.selectedOption.id)
305
+ ) {
306
+ state.selectedOption = null;
307
+ component.textfield.setValue('');
308
+ }
309
+
310
+ return component;
311
+ },
312
+
313
+ open: (event?: Event, interactionType: 'mouse' | 'keyboard' = 'mouse') => {
314
+ // Set focus restore flag for keyboard interactions
315
+ if (interactionType === 'keyboard') {
316
+ needsFocusRestore = true;
317
+ }
318
+
319
+ menu.open(event, interactionType);
320
+ return component;
321
+ },
322
+
323
+ close: () => {
324
+ menu.close();
325
+ return component;
326
+ },
327
+
328
+ isOpen: () => menu.isOpen()
329
+ }
330
+ };
331
+ };
@@ -0,0 +1,38 @@
1
+ // src/components/select/index.ts
2
+
3
+ /**
4
+ * Select Component Module
5
+ *
6
+ * The Select component provides a dropdown select control,
7
+ * combining a textfield and menu for a complete selection interface.
8
+ *
9
+ * @module components/select
10
+ * @category Components
11
+ */
12
+
13
+ // Export main component factory
14
+ export { default } from './select';
15
+
16
+ // Export types and interfaces
17
+ export type {
18
+ SelectConfig,
19
+ SelectComponent,
20
+ SelectOption,
21
+ SelectEvent,
22
+ SelectChangeEvent
23
+ } from './types';
24
+
25
+ /**
26
+ * Constants for select configuration
27
+ *
28
+ * @example
29
+ * import { createSelect, SELECT_VARIANTS } from 'mtrl';
30
+ *
31
+ * const select = createSelect({
32
+ * variant: SELECT_VARIANTS.OUTLINED,
33
+ * options: [...]
34
+ * });
35
+ *
36
+ * @category Components
37
+ */
38
+ export { SELECT_VARIANTS } from './types';
@@ -0,0 +1,73 @@
1
+ // src/components/select/select.ts
2
+
3
+ import { pipe } from '../../core/compose';
4
+ import { createBase } from '../../core/compose/component';
5
+ import { withEvents, withLifecycle } from '../../core/compose/features';
6
+ import { withTextfield, withMenu } from './features';
7
+ import { withAPI } from './api';
8
+ import { SelectConfig, SelectComponent } from './types';
9
+ import { createBaseConfig, getApiConfig } from './config';
10
+
11
+ /**
12
+ * Creates a new Select component with the specified configuration.
13
+ *
14
+ * The Select component implements a Material Design dropdown select control,
15
+ * combining a textfield and menu to provide a user-friendly selection interface.
16
+ *
17
+ * @param {SelectConfig} config - Configuration options for the select
18
+ * This must include an array of selection options. See {@link SelectConfig} for all available options.
19
+ *
20
+ * @returns {SelectComponent} A fully configured select component
21
+ *
22
+ * @throws {Error} Throws an error if select creation fails
23
+ *
24
+ * @example
25
+ * // Create a simple country select
26
+ * const countrySelect = createSelect({
27
+ * label: 'Country',
28
+ * options: [
29
+ * { id: 'us', text: 'United States' },
30
+ * { id: 'ca', text: 'Canada' },
31
+ * { id: 'mx', text: 'Mexico' }
32
+ * ],
33
+ * value: 'us' // Pre-select United States
34
+ * });
35
+ *
36
+ * // Add to the DOM
37
+ * document.body.appendChild(countrySelect.element);
38
+ *
39
+ * // Listen for changes
40
+ * countrySelect.on('change', (event) => {
41
+ * console.log(`Selected: ${event.value} (${event.text})`);
42
+ * });
43
+ */
44
+ const createSelect = (config: SelectConfig): SelectComponent => {
45
+ try {
46
+ // Validate and create the base configuration
47
+ const baseConfig = createBaseConfig(config);
48
+
49
+ // Create the component through functional composition
50
+ const select = pipe(
51
+ createBase,
52
+ withEvents(),
53
+ withTextfield(baseConfig),
54
+ withMenu(baseConfig),
55
+ withLifecycle(),
56
+ comp => withAPI(getApiConfig(comp))(comp)
57
+ )(baseConfig);
58
+
59
+ // Set up initial event handlers if provided
60
+ if (config.on) {
61
+ if (config.on.change) select.on('change', config.on.change);
62
+ if (config.on.open) select.on('open', config.on.open);
63
+ if (config.on.close) select.on('close', config.on.close);
64
+ }
65
+
66
+ return select;
67
+ } catch (error) {
68
+ console.error('Select creation error:', error);
69
+ throw new Error(`Failed to create select: ${(error as Error).message}`);
70
+ }
71
+ };
72
+
73
+ export default createSelect;