mtrl 0.0.1 → 0.0.3

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/TESTING.md ADDED
@@ -0,0 +1,104 @@
1
+ # Testing MTRL Components
2
+
3
+ This document provides guidelines for writing and running tests for the MTRL library. We use Bun's built-in test runner for fast, efficient testing.
4
+
5
+ ## Running Tests
6
+
7
+ ```bash
8
+ # Run all tests
9
+ bun test
10
+
11
+ # Run tests in watch mode (re-runs tests when files change)
12
+ bun test --watch
13
+
14
+ # Run tests with coverage report
15
+ bun test --coverage
16
+
17
+ # Run tests with UI
18
+ bun test --watch --ui
19
+
20
+ # Run specific test file or pattern
21
+ bun test test/components/button.test.js
22
+ ```
23
+
24
+ ## Test Structure
25
+
26
+ Tests are organized to mirror the source code structure:
27
+
28
+ ```
29
+ test/
30
+ ├── setup.js # Global test setup and DOM mocking
31
+ ├── components/ # Component tests
32
+ │ ├── button.test.js
33
+ │ ├── textfield.test.js
34
+ │ └── ...
35
+ └── core/ # Core functionality tests
36
+ ├── build/
37
+ │ └── ripple.test.js
38
+ ├── dom/
39
+ │ └── ...
40
+ └── state/
41
+ └── emitter.test.js
42
+ ```
43
+
44
+ ## Writing Tests
45
+
46
+ When writing tests for MTRL components, follow these guidelines:
47
+
48
+ ### 1. Test Component Creation
49
+
50
+ Always verify that components are created correctly with default options:
51
+
52
+ ```javascript
53
+ test('should create a component element', () => {
54
+ const component = createComponent();
55
+ expect(component.element).toBeDefined();
56
+ expect(component.element.tagName).toBe('DIV');
57
+ expect(component.element.className).toContain('mtrl-component');
58
+ });
59
+ ```
60
+
61
+ ### 2. Test Configuration Options
62
+
63
+ Test that configuration options properly affect the component:
64
+
65
+ ```javascript
66
+ test('should apply variant class', () => {
67
+ const variant = 'filled';
68
+ const component = createComponent({
69
+ variant
70
+ });
71
+
72
+ expect(component.element.className).toContain(`mtrl-component--${variant}`);
73
+ });
74
+ ```
75
+
76
+ ### 3. Test Events
77
+
78
+ Verify that events are properly emitted and handled:
79
+
80
+ ```javascript
81
+ test('should handle click events', () => {
82
+ const component = createComponent();
83
+ const handleClick = mock(() => {});
84
+
85
+ component.on('click', handleClick);
86
+
87
+ // Simulate event
88
+ const event = new Event('click');
89
+ component.element.dispatchEvent(event);
90
+
91
+ expect(handleClick).toHaveBeenCalled();
92
+ });
93
+ ```
94
+
95
+ ### 4. Test State Changes
96
+
97
+ Test component state changes through its API:
98
+
99
+ ```javascript
100
+ test('should support disabled state', () => {
101
+ const component = createComponent();
102
+
103
+ // Initially not disabled
104
+ expect(component.element.has
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "mtrl",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "A functional JavaScript component library with composable architecture based on Material Design 3",
5
5
  "keywords": ["component", "library", "ui", "user interface", "functional", "composable"],
6
6
  "main": "index.js",
7
7
  "scripts": {
8
- "test": "echo \"Error: no test specified\" && exit 1"
8
+ "test": "bun test",
9
+ "test:watch": "bun test --watch",
10
+ "test:coverage": "bun test --coverage",
11
+ "test:ui": "bun test --watch --ui"
9
12
  },
10
13
  "repository": {
11
14
  "type": "git",
@@ -13,5 +16,5 @@
13
16
  },
14
17
 
15
18
  "author": "floor",
16
- "license": "GPL-3.0"
19
+ "license": "MIT License"
17
20
  }
@@ -1,4 +1,6 @@
1
- import PREFIX from '../../core/config'
1
+ // src/components/button/config.js
2
+
3
+ import { PREFIX } from '../../core/config'
2
4
 
3
5
  const defaultConfig = {
4
6
  componentName: 'button',
@@ -82,7 +82,7 @@ const createCheckbox = (config = {}) => {
82
82
  withCheckIcon(baseConfig),
83
83
  withTextLabel(baseConfig),
84
84
  enhancedWithCheckable,
85
- withDisabled(),
85
+ withDisabled(baseConfig), // Pass the baseConfig to withDisabled
86
86
  withLifecycle(),
87
87
  comp => withAPI({
88
88
  disabled: comp.disabled,
@@ -157,13 +157,13 @@
157
157
  &::before {
158
158
  content: '';
159
159
  position: absolute;
160
- top: -8px;
161
- left: -8px;
162
- right: -8px;
163
- bottom: -8px;
160
+ top: -12px;
161
+ left: -12px;
162
+ right: -12px;
163
+ bottom: -12px;
164
164
  background-color: var(--mtrl-sys-color-on-surface);
165
165
  opacity: 0.08;
166
- border-radius: 4px;
166
+ border-radius: 50%;
167
167
  }
168
168
  }
169
169
  }
@@ -30,6 +30,11 @@ export const LIST_CLASSES = {
30
30
  SECTION_TITLE: 'list-section-title'
31
31
  }
32
32
 
33
+ export const LIST_ITEM_LAYOUTS = {
34
+ HORIZONTAL: 'horizontal',
35
+ VERTICAL: 'vertical'
36
+ }
37
+
33
38
  /**
34
39
  * List configuration schema
35
40
  */
@@ -1,17 +1,9 @@
1
1
  // src/components/list/list-item.js
2
-
3
2
  import { PREFIX } from '../../core/config'
4
3
  import { pipe } from '../../core/compose'
5
4
  import { createBase, withElement } from '../../core/compose/component'
6
5
  import { withEvents, withDisabled } from '../../core/compose/features'
7
-
8
- /**
9
- * Supported list item layouts
10
- */
11
- export const LIST_ITEM_LAYOUTS = {
12
- HORIZONTAL: 'horizontal', // Default horizontal layout
13
- VERTICAL: 'vertical' // Stacked layout with vertical alignment
14
- }
6
+ import { LIST_ITEM_LAYOUTS } from './constants'
15
7
 
16
8
  /**
17
9
  * Creates a DOM element with optional class and content
@@ -56,8 +48,7 @@ const createListItem = (config = {}) => {
56
48
  }
57
49
 
58
50
  const createContent = (component) => {
59
- const { element } = component
60
- const { prefix } = baseConfig
51
+ const { element, prefix } = component
61
52
  const isVertical = config.layout === LIST_ITEM_LAYOUTS.VERTICAL
62
53
 
63
54
  // Create content container
@@ -137,7 +128,8 @@ const createListItem = (config = {}) => {
137
128
  tag: 'div',
138
129
  role: config.role || 'listitem',
139
130
  componentName: 'list-item',
140
- className: `${config.layout === LIST_ITEM_LAYOUTS.VERTICAL ? 'vertical' : ''} ${config.class || ''}`
131
+ // Ensure that every list item includes the prefix-based class
132
+ className: `${baseConfig.prefix}-list-item ${config.layout === LIST_ITEM_LAYOUTS.VERTICAL ? 'vertical' : ''} ${config.class || ''}`.trim()
141
133
  }),
142
134
  withDisabled(),
143
135
  createContent
@@ -1,5 +1,3 @@
1
- // src/components/list/list.js
2
-
3
1
  import { PREFIX } from '../../core/config'
4
2
  import { pipe } from '../../core/compose'
5
3
  import { createBase, withElement } from '../../core/compose/component'
@@ -56,29 +54,29 @@ const createList = (config = {}) => {
56
54
  const focusedItem = document.activeElement
57
55
  if (!focusedItem?.classList.contains(`${prefix}-list-item`)) return
58
56
 
59
- const items = Array.from(element.querySelectorAll(`.${prefix}-list-item`))
60
- const currentIndex = items.indexOf(focusedItem)
57
+ const allItems = Array.from(element.querySelectorAll(`.${prefix}-list-item`))
58
+ const currentIndex = allItems.indexOf(focusedItem)
61
59
 
62
60
  switch (event.key) {
63
61
  case 'ArrowDown':
64
62
  case 'ArrowRight':
65
63
  event.preventDefault()
66
- const nextItem = items[currentIndex + 1]
64
+ const nextItem = allItems[currentIndex + 1]
67
65
  if (nextItem) nextItem.focus()
68
66
  break
69
67
  case 'ArrowUp':
70
68
  case 'ArrowLeft':
71
69
  event.preventDefault()
72
- const prevItem = items[currentIndex - 1]
70
+ const prevItem = allItems[currentIndex - 1]
73
71
  if (prevItem) prevItem.focus()
74
72
  break
75
73
  case 'Home':
76
74
  event.preventDefault()
77
- items[0]?.focus()
75
+ allItems[0]?.focus()
78
76
  break
79
77
  case 'End':
80
78
  event.preventDefault()
81
- items[items.length - 1]?.focus()
79
+ allItems[allItems.length - 1]?.focus()
82
80
  break
83
81
  case ' ':
84
82
  case 'Enter':
@@ -187,7 +185,7 @@ const createList = (config = {}) => {
187
185
 
188
186
  element.addEventListener('keydown', handleKeyDown)
189
187
 
190
- // Clean up
188
+ // Clean up lifecycle if defined
191
189
  if (component.lifecycle) {
192
190
  const originalDestroy = component.lifecycle.destroy
193
191
  component.lifecycle.destroy = () => {
@@ -248,12 +246,14 @@ const createList = (config = {}) => {
248
246
  }
249
247
  }
250
248
 
251
- return pipe(
249
+ const list = pipe(
252
250
  createBase,
253
251
  withEvents(),
254
252
  withElement({
255
253
  tag: 'div',
256
- role: config.type === LIST_TYPES.DEFAULT ? 'list' : 'listbox',
254
+ // Use role "list" for default (non-selectable) lists,
255
+ // and "listbox" for interactive, selectable types.
256
+ role: (!config.type || config.type === LIST_TYPES.DEFAULT) ? 'list' : 'listbox',
257
257
  'aria-multiselectable': config.type === LIST_TYPES.MULTI_SELECT ? 'true' : undefined,
258
258
  componentName: LIST_CLASSES.ROOT,
259
259
  className: config.class
@@ -262,6 +262,14 @@ const createList = (config = {}) => {
262
262
  withLifecycle(),
263
263
  createContent
264
264
  )(baseConfig)
265
+
266
+ // Ensure that for default lists, the role is correctly "list"
267
+ if (!config.type || config.type === LIST_TYPES.DEFAULT) {
268
+ list.element.setAttribute('role', 'list')
269
+ }
270
+ // Expose the prefix on the returned component for testing purposes.
271
+ list.prefix = baseConfig.prefix
272
+ return list
265
273
  }
266
274
 
267
275
  export default createList
@@ -331,6 +331,10 @@ export const withItemsManager = (config) => (component) => {
331
331
  removeItem (name) {
332
332
  if (!name) return this
333
333
 
334
+ // First, ensure we remove the item from our internal map
335
+ itemsMap.delete(name)
336
+
337
+ // Now try to remove the item from the DOM
334
338
  const item = list.querySelector(`[data-name="${name}"]`)
335
339
  if (item) {
336
340
  // Remove event listeners
@@ -344,8 +348,8 @@ export const withItemsManager = (config) => (component) => {
344
348
  submenus.delete(name)
345
349
  }
346
350
 
351
+ // Remove the item from the DOM
347
352
  item.remove()
348
- itemsMap.delete(name)
349
353
  }
350
354
 
351
355
  return this
@@ -121,8 +121,17 @@ export const NAV_SCHEMA = {
121
121
  }
122
122
  }
123
123
 
124
+ /**
125
+ * Navigation item states
126
+ */
127
+ export const NAV_ITEM_STATES = {
128
+ EXPANDED: 'expanded',
129
+ COLLAPSED: 'collapsed'
130
+ }
131
+
124
132
  /**
125
133
  * Navigation item schema
134
+ * Enhanced with support for nested items
126
135
  */
127
136
  export const NAV_ITEM_SCHEMA = {
128
137
  type: 'object',
@@ -154,82 +163,38 @@ export const NAV_ITEM_SCHEMA = {
154
163
  groupId: {
155
164
  type: 'string',
156
165
  optional: true
157
- }
158
- }
159
- }
160
-
161
- /**
162
- * Navigation group schema
163
- */
164
- export const NAV_GROUP_SCHEMA = {
165
- type: 'object',
166
- properties: {
167
- id: {
168
- type: 'string',
169
- required: true
170
166
  },
171
- title: {
172
- type: 'string',
173
- required: true
167
+ items: {
168
+ type: 'array',
169
+ optional: true,
170
+ description: 'Nested navigation items'
174
171
  },
175
172
  expanded: {
176
173
  type: 'boolean',
177
174
  optional: true,
178
- default: true
175
+ default: false
179
176
  }
180
177
  }
181
178
  }
182
179
 
183
180
  /**
184
- * Navigation item states
181
+ * Navigation group schema
185
182
  */
186
- export const NAV_ITEM_STATES = {
187
- EXPANDED: 'expanded',
188
- COLLAPSED: 'collapsed'
189
- }
190
-
191
-
192
- // Update NAV_ITEM_SCHEMA to support nested items
193
- export const NAV_ITEM_SCHEMA = {
183
+ export const NAV_GROUP_SCHEMA = {
194
184
  type: 'object',
195
185
  properties: {
196
186
  id: {
197
187
  type: 'string',
198
188
  required: true
199
189
  },
200
- icon: {
201
- type: 'string',
202
- required: true
203
- },
204
- label: {
190
+ title: {
205
191
  type: 'string',
206
192
  required: true
207
193
  },
208
- badge: {
209
- type: 'string',
210
- optional: true
211
- },
212
- disabled: {
213
- type: 'boolean',
214
- optional: true
215
- },
216
- subtitle: {
217
- type: 'string',
218
- optional: true
219
- },
220
- groupId: {
221
- type: 'string',
222
- optional: true
223
- },
224
- items: {
225
- type: 'array',
226
- optional: true,
227
- description: 'Nested navigation items'
228
- },
229
194
  expanded: {
230
195
  type: 'boolean',
231
196
  optional: true,
232
- default: false
197
+ default: true
233
198
  }
234
199
  }
235
- }
200
+ }
@@ -136,7 +136,24 @@
136
136
  }
137
137
 
138
138
  &--disabled {
139
- opacity: .5;
139
+ opacity: 0.38;
140
+
141
+ // Specific styles for disabled + checked
142
+ &.#{c.$prefix}-switch--checked {
143
+ .#{c.$prefix}-switch-track {
144
+ background-color: var(--mtrl-sys-color-outline);
145
+ border-color: var(--mtrl-sys-color-outline);
146
+ opacity: 0.38;
147
+ }
148
+
149
+ .#{c.$prefix}-switch-thumb {
150
+ background-color: var(--mtrl-sys-color-on-primary);
151
+ opacity: 1;
152
+ &-icon {
153
+ color: var(--mtrl-sys-color-outline)
154
+ }
155
+ }
156
+ }
140
157
  }
141
158
 
142
159
  // Hover effects
@@ -58,7 +58,7 @@ const createSwitch = (config = {}) => {
58
58
  withTextLabel(baseConfig),
59
59
  withLabelPosition(baseConfig),
60
60
  withCheckable(baseConfig),
61
- withDisabled(),
61
+ withDisabled(baseConfig), // Pass the config to ensure disabled state is properly initialized
62
62
  withLifecycle(),
63
63
  comp => withAPI({
64
64
  disabled: comp.disabled,
@@ -13,7 +13,13 @@ const DEFAULT_CONFIG = RIPPLE_CONFIG
13
13
  * @returns {Object} Ripple controller instance
14
14
  */
15
15
  export const createRipple = (config = {}) => {
16
- const options = { ...DEFAULT_CONFIG, ...config }
16
+ // Make sure we fully merge the config options
17
+ const options = {
18
+ ...DEFAULT_CONFIG,
19
+ ...config,
20
+ // Handle nested objects like opacity array
21
+ opacity: config.opacity || DEFAULT_CONFIG.opacity
22
+ }
17
23
 
18
24
  const getEndCoordinates = (bounds) => {
19
25
  const size = Math.max(bounds.width, bounds.height)
@@ -36,7 +42,29 @@ export const createRipple = (config = {}) => {
36
42
  return ripple
37
43
  }
38
44
 
45
+ // Store document event listeners for cleanup
46
+ let documentListeners = []
47
+
48
+ // Safe document event handling
49
+ const addDocumentListener = (event, handler) => {
50
+ if (typeof document.addEventListener === 'function') {
51
+ document.addEventListener(event, handler)
52
+ documentListeners.push({ event, handler })
53
+ }
54
+ }
55
+
56
+ const removeDocumentListener = (event, handler) => {
57
+ if (typeof document.removeEventListener === 'function') {
58
+ document.removeEventListener(event, handler)
59
+ documentListeners = documentListeners.filter(
60
+ listener => !(listener.event === event && listener.handler === handler)
61
+ )
62
+ }
63
+ }
64
+
39
65
  const animate = (event, container) => {
66
+ if (!container) return
67
+
40
68
  const bounds = container.getBoundingClientRect()
41
69
  const ripple = createRippleElement()
42
70
 
@@ -49,7 +77,10 @@ export const createRipple = (config = {}) => {
49
77
  })
50
78
 
51
79
  container.appendChild(ripple)
52
- ripple.offsetHeight // Force reflow
80
+
81
+ // Force reflow
82
+ // eslint-disable-next-line no-unused-expressions
83
+ ripple.offsetHeight
53
84
 
54
85
  // Animate to end position
55
86
  const end = getEndCoordinates(bounds)
@@ -61,13 +92,20 @@ export const createRipple = (config = {}) => {
61
92
 
62
93
  const cleanup = () => {
63
94
  ripple.style.opacity = '0'
64
- setTimeout(() => ripple.remove(), options.duration)
65
- document.removeEventListener('mouseup', cleanup)
66
- document.removeEventListener('mouseleave', cleanup)
95
+
96
+ // Use setTimeout to remove element after animation
97
+ setTimeout(() => {
98
+ if (ripple.parentNode) {
99
+ ripple.parentNode.removeChild(ripple)
100
+ }
101
+ }, options.duration)
102
+
103
+ removeDocumentListener('mouseup', cleanup)
104
+ removeDocumentListener('mouseleave', cleanup)
67
105
  }
68
106
 
69
- document.addEventListener('mouseup', cleanup)
70
- document.addEventListener('mouseleave', cleanup)
107
+ addDocumentListener('mouseup', cleanup)
108
+ addDocumentListener('mouseleave', cleanup)
71
109
  }
72
110
 
73
111
  return {
@@ -81,12 +119,41 @@ export const createRipple = (config = {}) => {
81
119
  }
82
120
  element.style.overflow = 'hidden'
83
121
 
84
- element.addEventListener('mousedown', (e) => animate(e, element))
122
+ // Store the mousedown handler to be able to remove it later
123
+ const mousedownHandler = (e) => animate(e, element)
124
+
125
+ // Store handler reference on the element
126
+ if (!element.__rippleHandlers) {
127
+ element.__rippleHandlers = []
128
+ }
129
+ element.__rippleHandlers.push(mousedownHandler)
130
+
131
+ element.addEventListener('mousedown', mousedownHandler)
85
132
  },
86
133
 
87
134
  unmount: (element) => {
88
135
  if (!element) return
89
- element.querySelectorAll('.ripple').forEach(ripple => ripple.remove())
136
+
137
+ // Clear document event listeners
138
+ documentListeners.forEach(({ event, handler }) => {
139
+ removeDocumentListener(event, handler)
140
+ })
141
+ documentListeners = []
142
+
143
+ // Remove event listeners
144
+ if (element.__rippleHandlers) {
145
+ element.__rippleHandlers.forEach(handler => {
146
+ element.removeEventListener('mousedown', handler)
147
+ })
148
+ element.__rippleHandlers = []
149
+ }
150
+
151
+ // Remove all ripple elements
152
+ const ripples = element.querySelectorAll('.ripple')
153
+ ripples.forEach(ripple => {
154
+ // Call remove directly to match the test expectation
155
+ ripple.remove()
156
+ })
90
157
  }
91
158
  }
92
159
  }
@@ -1,25 +1,64 @@
1
1
  // src/core/compose/features/disabled.js
2
2
 
3
- export const withDisabled = (config) => (component) => {
4
- if (!component.element) return component
3
+ /**
4
+ * Adds disabled state management to a component
5
+ * @param {Object} config - Disabled configuration
6
+ * @returns {Function} Component enhancer
7
+ */
8
+ export const withDisabled = (config = {}) => (component) => {
9
+ // Get the disabled class based on component name
10
+ const disabledClass = `${component.getClass(component.componentName)}--disabled`
5
11
 
6
- return {
7
- ...component,
8
- disabled: {
9
- enable () {
10
- console.debug('disabled')
12
+ // Directly implement disabled functionality
13
+ const disabled = {
14
+ enable () {
15
+ component.element.classList.remove(disabledClass)
16
+ if (component.input) {
17
+ component.input.disabled = false
18
+ component.input.removeAttribute('disabled')
19
+ } else {
11
20
  component.element.disabled = false
12
- const className = `${config.prefix}-${config.componentName}--disable`
13
- component.element.classList.remove(className)
14
- return this
15
- },
16
- disable () {
17
- console.debug('disabled')
21
+ component.element.removeAttribute('disabled')
22
+ }
23
+ return this
24
+ },
25
+
26
+ disable () {
27
+ component.element.classList.add(disabledClass)
28
+ if (component.input) {
29
+ component.input.disabled = true
30
+ component.input.setAttribute('disabled', 'true')
31
+ } else {
18
32
  component.element.disabled = true
19
- const className = `${config.prefix}-${config.componentName}--disable`
20
- component.element.classList.add(className)
21
- return this
33
+ component.element.setAttribute('disabled', 'true')
22
34
  }
35
+ return this
36
+ },
37
+
38
+ toggle () {
39
+ if (this.isDisabled()) {
40
+ this.enable()
41
+ } else {
42
+ this.disable()
43
+ }
44
+ return this
45
+ },
46
+
47
+ isDisabled () {
48
+ return component.input ? component.input.disabled : component.element.disabled
23
49
  }
24
50
  }
51
+
52
+ // Initialize disabled state if configured
53
+ if (config.disabled) {
54
+ // Use requestAnimationFrame to ensure DOM is ready
55
+ requestAnimationFrame(() => {
56
+ disabled.disable()
57
+ })
58
+ }
59
+
60
+ return {
61
+ ...component,
62
+ disabled
63
+ }
25
64
  }