mtrl 0.0.1 → 0.0.2

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.2",
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",
@@ -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',
@@ -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,44 @@
1
- // src/core/compose/features/disabled.js
2
1
 
3
- export const withDisabled = (config) => (component) => {
4
- if (!component.element) return component
2
+ /**
3
+ * Adds disabled state management to a component
4
+ * @param {Object} config - Disabled configuration
5
+ * @returns {Function} Component enhancer
6
+ */
7
+ export const withDisabled = (config = {}) => (component) => {
8
+ // Directly implement disabled functionality
9
+ const disabled = {
10
+ enable () {
11
+ component.element.disabled = false
12
+ component.element.removeAttribute('disabled')
13
+ return this
14
+ },
5
15
 
6
- return {
7
- ...component,
8
- disabled: {
9
- enable () {
10
- console.debug('disabled')
11
- 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')
18
- component.element.disabled = true
19
- const className = `${config.prefix}-${config.componentName}--disable`
20
- component.element.classList.add(className)
21
- return this
16
+ disable () {
17
+ component.element.disabled = true
18
+ component.element.setAttribute('disabled', 'true')
19
+ return this
20
+ },
21
+
22
+ toggle () {
23
+ if (component.element.disabled) {
24
+ this.enable()
25
+ } else {
26
+ this.disable()
22
27
  }
28
+ return this
29
+ },
30
+
31
+ isDisabled () {
32
+ return component.element.disabled === true
23
33
  }
24
34
  }
35
+
36
+ if (config.disabled) {
37
+ disabled.disable()
38
+ }
39
+
40
+ return {
41
+ ...component,
42
+ disabled
43
+ }
25
44
  }
@@ -1,14 +1,51 @@
1
1
  // src/core/state/disabled.js
2
+
3
+ /**
4
+ * Creates a controller for managing the disabled state of an element
5
+ * @param {HTMLElement} element - The element to control
6
+ * @returns {Object} Disabled state controller
7
+ */
2
8
  export const createDisabled = (element) => {
3
9
  return {
4
- enable() {
10
+ /**
11
+ * Enables the element
12
+ * @returns {Object} The controller instance for chaining
13
+ */
14
+ enable () {
5
15
  element.disabled = false
16
+ element.removeAttribute('disabled')
6
17
  return this
7
18
  },
8
-
9
- disable() {
19
+
20
+ /**
21
+ * Disables the element
22
+ * @returns {Object} The controller instance for chaining
23
+ */
24
+ disable () {
10
25
  element.disabled = true
26
+ element.setAttribute('disabled', 'true')
11
27
  return this
28
+ },
29
+
30
+ /**
31
+ * Toggles the disabled state
32
+ * @returns {Object} The controller instance for chaining
33
+ */
34
+ toggle () {
35
+ if (element.disabled) {
36
+ this.enable()
37
+ } else {
38
+ this.disable()
39
+ }
40
+ return this
41
+ },
42
+
43
+ /**
44
+ * Checks if the element is disabled
45
+ * @returns {boolean} True if the element is disabled
46
+ */
47
+ isDisabled () {
48
+ return element.disabled === true
12
49
  }
13
50
  }
14
- }
51
+ }
@@ -0,0 +1,158 @@
1
+ // test/components/button.test.js
2
+ import { describe, test, expect, mock } from 'bun:test'
3
+ import createButton from '../../src/components/button/button'
4
+
5
+ describe('Button Component', () => {
6
+ test('should create a button element', () => {
7
+ const button = createButton()
8
+ expect(button.element).toBeDefined()
9
+ expect(button.element.tagName).toBe('BUTTON')
10
+ expect(button.element.className).toContain('mtrl-button')
11
+ })
12
+
13
+ test('should add text content', () => {
14
+ const buttonText = 'Click Me'
15
+ const button = createButton({
16
+ text: buttonText
17
+ })
18
+
19
+ const textElement = button.element.querySelector('.mtrl-button-text')
20
+ expect(textElement).toBeDefined()
21
+ expect(textElement.textContent).toBe(buttonText)
22
+ })
23
+
24
+ test('should apply variant class', () => {
25
+ const variant = 'filled'
26
+ const button = createButton({
27
+ variant
28
+ })
29
+
30
+ expect(button.element.className).toContain(`mtrl-button--${variant}`)
31
+ })
32
+
33
+ test('should handle click events', () => {
34
+ const button = createButton()
35
+ const handleClick = mock(() => {})
36
+
37
+ button.on('click', handleClick)
38
+
39
+ // Simulate click event
40
+ const event = new Event('click')
41
+ button.element.dispatchEvent(event)
42
+
43
+ expect(handleClick).toHaveBeenCalled()
44
+ })
45
+
46
+ test('should support disabled state', () => {
47
+ const button = createButton()
48
+
49
+ // Initially not disabled
50
+ expect(button.element.hasAttribute('disabled')).toBe(false)
51
+
52
+ // Disable the button
53
+ button.disable()
54
+ expect(button.element.hasAttribute('disabled')).toBe(true)
55
+
56
+ // Check that the disabled property is also set
57
+ expect(button.element.disabled).toBe(true)
58
+
59
+ // Enable the button
60
+ button.enable()
61
+ expect(button.element.hasAttribute('disabled')).toBe(false)
62
+ expect(button.element.disabled).toBe(false)
63
+ })
64
+
65
+ test('should add icon content', () => {
66
+ const iconSvg = '<svg><path d="M10 10"></path></svg>'
67
+ const button = createButton({
68
+ icon: iconSvg
69
+ })
70
+
71
+ const iconElement = button.element.querySelector('.mtrl-button-icon')
72
+ expect(iconElement).toBeDefined()
73
+ expect(iconElement.innerHTML).toBe(iconSvg)
74
+ })
75
+
76
+ test('should position icon correctly', () => {
77
+ const iconSvg = '<svg><path d="M10 10"></path></svg>'
78
+
79
+ // Test end position
80
+ const endButton = createButton({
81
+ text: 'End Icon',
82
+ icon: iconSvg,
83
+ iconPosition: 'end'
84
+ })
85
+
86
+ const textElement = endButton.element.querySelector('.mtrl-button-text')
87
+ const iconElement = endButton.element.querySelector('.mtrl-button-icon')
88
+
89
+ // In the DOM, for end position, the text should come before the icon
90
+ const children = Array.from(endButton.element.childNodes)
91
+ const textIndex = children.indexOf(textElement)
92
+ const iconIndex = children.indexOf(iconElement)
93
+
94
+ expect(textIndex).toBeLessThan(iconIndex)
95
+
96
+ // Test start position
97
+ const startButton = createButton({
98
+ text: 'Start Icon',
99
+ icon: iconSvg,
100
+ iconPosition: 'start'
101
+ })
102
+
103
+ const startTextElement = startButton.element.querySelector('.mtrl-button-text')
104
+ const startIconElement = startButton.element.querySelector('.mtrl-button-icon')
105
+
106
+ const startChildren = Array.from(startButton.element.childNodes)
107
+ const startTextIndex = startChildren.indexOf(startTextElement)
108
+ const startIconIndex = startChildren.indexOf(startIconElement)
109
+
110
+ expect(startIconIndex).toBeLessThan(startTextIndex)
111
+ })
112
+
113
+ test('should support different sizes', () => {
114
+ const sizes = ['small', 'medium', 'large']
115
+
116
+ sizes.forEach(size => {
117
+ const button = createButton({
118
+ size
119
+ })
120
+
121
+ expect(button.element.className).toContain(`mtrl-button--${size}`)
122
+ })
123
+ })
124
+
125
+ test('should allow updating text', () => {
126
+ const button = createButton({
127
+ text: 'Initial'
128
+ })
129
+
130
+ const newText = 'Updated Text'
131
+ button.setText(newText)
132
+
133
+ const textElement = button.element.querySelector('.mtrl-button-text')
134
+ expect(textElement.textContent).toBe(newText)
135
+ })
136
+
137
+ test('should allow updating icon', () => {
138
+ const button = createButton()
139
+
140
+ const iconSvg = '<svg><path d="M10 10"></path></svg>'
141
+ button.setIcon(iconSvg)
142
+
143
+ const iconElement = button.element.querySelector('.mtrl-button-icon')
144
+ expect(iconElement).toBeDefined()
145
+ expect(iconElement.innerHTML).toBe(iconSvg)
146
+ })
147
+
148
+ test('should properly clean up resources', () => {
149
+ const button = createButton()
150
+ const parentElement = document.createElement('div')
151
+ parentElement.appendChild(button.element)
152
+
153
+ // Destroy should remove the element and clean up resources
154
+ button.destroy()
155
+
156
+ expect(parentElement.children.length).toBe(0)
157
+ })
158
+ })
@@ -0,0 +1,141 @@
1
+ // test/core/state/emitter.test.js
2
+ import { describe, test, expect, mock } from 'bun:test'
3
+ import { createEmitter } from '../../src/core/state/emitter'
4
+
5
+ describe('Event Emitter', () => {
6
+ test('should create an emitter with expected methods', () => {
7
+ const emitter = createEmitter()
8
+ expect(emitter).toBeDefined()
9
+ expect(emitter.on).toBeInstanceOf(Function)
10
+ expect(emitter.off).toBeInstanceOf(Function)
11
+ expect(emitter.emit).toBeInstanceOf(Function)
12
+ expect(emitter.clear).toBeInstanceOf(Function)
13
+ })
14
+
15
+ test('should register event handlers with on()', () => {
16
+ const emitter = createEmitter()
17
+ const handler = mock(() => {})
18
+
19
+ const unsubscribe = emitter.on('test', handler)
20
+
21
+ expect(unsubscribe).toBeInstanceOf(Function)
22
+ })
23
+
24
+ test('should invoke handlers when event is emitted', () => {
25
+ const emitter = createEmitter()
26
+ const handler1 = mock(() => {})
27
+ const handler2 = mock(() => {})
28
+ const eventData = { foo: 'bar' }
29
+
30
+ emitter.on('test', handler1)
31
+ emitter.on('test', handler2)
32
+
33
+ emitter.emit('test', eventData)
34
+
35
+ expect(handler1).toHaveBeenCalledTimes(1)
36
+ expect(handler1).toHaveBeenCalledWith(eventData)
37
+
38
+ expect(handler2).toHaveBeenCalledTimes(1)
39
+ expect(handler2).toHaveBeenCalledWith(eventData)
40
+ })
41
+
42
+ test('should remove handlers with off()', () => {
43
+ const emitter = createEmitter()
44
+ const handler = mock(() => {})
45
+
46
+ emitter.on('test', handler)
47
+ emitter.off('test', handler)
48
+
49
+ emitter.emit('test')
50
+
51
+ expect(handler).not.toHaveBeenCalled()
52
+ })
53
+
54
+ test('should remove specific handlers while keeping others', () => {
55
+ const emitter = createEmitter()
56
+ const handler1 = mock(() => {})
57
+ const handler2 = mock(() => {})
58
+
59
+ emitter.on('test', handler1)
60
+ emitter.on('test', handler2)
61
+
62
+ emitter.off('test', handler1)
63
+
64
+ emitter.emit('test')
65
+
66
+ expect(handler1).not.toHaveBeenCalled()
67
+ expect(handler2).toHaveBeenCalledTimes(1)
68
+ })
69
+
70
+ test('should unsubscribe handlers using the returned function', () => {
71
+ const emitter = createEmitter()
72
+ const handler = mock(() => {})
73
+
74
+ const unsubscribe = emitter.on('test', handler)
75
+ unsubscribe()
76
+
77
+ emitter.emit('test')
78
+
79
+ expect(handler).not.toHaveBeenCalled()
80
+ })
81
+
82
+ test('should support multiple event types', () => {
83
+ const emitter = createEmitter()
84
+ const handler1 = mock(() => {})
85
+ const handler2 = mock(() => {})
86
+
87
+ emitter.on('event1', handler1)
88
+ emitter.on('event2', handler2)
89
+
90
+ emitter.emit('event1')
91
+
92
+ expect(handler1).toHaveBeenCalledTimes(1)
93
+ expect(handler2).not.toHaveBeenCalled()
94
+
95
+ emitter.emit('event2')
96
+
97
+ expect(handler1).toHaveBeenCalledTimes(1)
98
+ expect(handler2).toHaveBeenCalledTimes(1)
99
+ })
100
+
101
+ test('should clear all event handlers', () => {
102
+ const emitter = createEmitter()
103
+ const handler1 = mock(() => {})
104
+ const handler2 = mock(() => {})
105
+
106
+ emitter.on('event1', handler1)
107
+ emitter.on('event2', handler2)
108
+
109
+ emitter.clear()
110
+
111
+ emitter.emit('event1')
112
+ emitter.emit('event2')
113
+
114
+ expect(handler1).not.toHaveBeenCalled()
115
+ expect(handler2).not.toHaveBeenCalled()
116
+ })
117
+
118
+ test('should pass multiple arguments to handlers', () => {
119
+ const emitter = createEmitter()
120
+ const handler = mock(() => {})
121
+
122
+ emitter.on('test', handler)
123
+
124
+ const arg1 = { id: 1 }
125
+ const arg2 = 'string'
126
+ const arg3 = [1, 2, 3]
127
+
128
+ emitter.emit('test', arg1, arg2, arg3)
129
+
130
+ expect(handler).toHaveBeenCalledWith(arg1, arg2, arg3)
131
+ })
132
+
133
+ test('should do nothing when emitting event with no handlers', () => {
134
+ const emitter = createEmitter()
135
+
136
+ // This should not throw
137
+ expect(() => {
138
+ emitter.emit('nonexistent')
139
+ }).not.toThrow()
140
+ })
141
+ })
@@ -0,0 +1,165 @@
1
+ // test/core/build/ripple.test.js
2
+ import { describe, test, expect, mock, spyOn } from 'bun:test'
3
+ import { createRipple } from '../../src/core/build/ripple'
4
+ import '../setup'
5
+
6
+ describe('Ripple Effect', () => {
7
+ test('should create a ripple controller', () => {
8
+ const ripple = createRipple()
9
+ expect(ripple).toBeDefined()
10
+ expect(ripple.mount).toBeInstanceOf(Function)
11
+ expect(ripple.unmount).toBeInstanceOf(Function)
12
+ })
13
+
14
+ test('should mount ripple effect to an element', () => {
15
+ const ripple = createRipple()
16
+ const element = document.createElement('div')
17
+
18
+ // Element should start with static position
19
+ element.style.position = 'static'
20
+
21
+ ripple.mount(element)
22
+
23
+ // Position should now be relative for proper ripple positioning
24
+ expect(element.style.position).toBe('relative')
25
+ expect(element.style.overflow).toBe('hidden')
26
+
27
+ // Should have added mousedown event listener
28
+ expect(element.__handlers).toBeDefined()
29
+ expect(element.__handlers.mousedown).toBeDefined()
30
+ })
31
+
32
+ test('should not fail when mounting to a null element', () => {
33
+ const ripple = createRipple()
34
+ expect(() => ripple.mount(null)).not.toThrow()
35
+ })
36
+
37
+ test('should create ripple element on mousedown', () => {
38
+ const ripple = createRipple()
39
+ const element = document.createElement('div')
40
+
41
+ // Spy on appendChild to verify ripple element creation
42
+ const appendChildSpy = spyOn(element, 'appendChild')
43
+
44
+ ripple.mount(element)
45
+
46
+ // Simulate mousedown event
47
+ const mouseEvent = {
48
+ type: 'mousedown',
49
+ offsetX: 10,
50
+ offsetY: 20,
51
+ target: element
52
+ }
53
+ element.__handlers.mousedown[0](mouseEvent)
54
+
55
+ // Should have created and appended a ripple element
56
+ expect(appendChildSpy).toHaveBeenCalled()
57
+ expect(appendChildSpy.mock.calls[0][0].className).toBe('ripple')
58
+ })
59
+
60
+ test('should add document cleanup event listeners', () => {
61
+ const ripple = createRipple()
62
+ const element = document.createElement('div')
63
+
64
+ // Mock document event listeners
65
+ const docAddEventListener = mock(() => {})
66
+ const originalDocAddEventListener = document.addEventListener
67
+ document.addEventListener = docAddEventListener
68
+
69
+ ripple.mount(element)
70
+
71
+ // Simulate mousedown event
72
+ const mouseEvent = {
73
+ type: 'mousedown',
74
+ offsetX: 10,
75
+ offsetY: 20,
76
+ target: element
77
+ }
78
+ element.__handlers.mousedown[0](mouseEvent)
79
+
80
+ // Should have added mouseup and mouseleave event listeners to document
81
+ expect(docAddEventListener).toHaveBeenCalledTimes(2)
82
+ expect(docAddEventListener.mock.calls[0][0]).toBe('mouseup')
83
+ expect(docAddEventListener.mock.calls[1][0]).toBe('mouseleave')
84
+
85
+ // Restore original
86
+ document.addEventListener = originalDocAddEventListener
87
+ })
88
+
89
+ test('should remove ripple elements on unmount', () => {
90
+ const ripple = createRipple()
91
+ const element = document.createElement('div')
92
+
93
+ // Add a few ripple elements
94
+ const ripple1 = document.createElement('div')
95
+ ripple1.className = 'ripple'
96
+ const ripple2 = document.createElement('div')
97
+ ripple2.className = 'ripple'
98
+
99
+ element.appendChild(ripple1)
100
+ element.appendChild(ripple2)
101
+
102
+ // Mock the querySelectorAll and forEach methods
103
+ element.querySelectorAll = (selector) => {
104
+ if (selector === '.ripple') {
105
+ return [ripple1, ripple2]
106
+ }
107
+ return []
108
+ }
109
+
110
+ const removeSpy1 = spyOn(ripple1, 'remove')
111
+ const removeSpy2 = spyOn(ripple2, 'remove')
112
+
113
+ ripple.unmount(element)
114
+
115
+ // Should have removed both ripple elements
116
+ expect(removeSpy1).toHaveBeenCalled()
117
+ expect(removeSpy2).toHaveBeenCalled()
118
+ })
119
+
120
+ // test('should handle custom config options', () => {
121
+ // const customConfig = {
122
+ // duration: 500,
123
+ // timing: 'ease-out',
124
+ // opacity: ['0.8', '0.2']
125
+ // }
126
+
127
+ // const ripple = createRipple(customConfig)
128
+ // const element = document.createElement('div')
129
+
130
+ // // Spy on appendChild to capture the ripple element
131
+ // let capturedRipple
132
+ // const originalAppendChild = element.appendChild
133
+ // element.appendChild = (child) => {
134
+ // capturedRipple = child
135
+ // return originalAppendChild.call(element, child)
136
+ // }
137
+
138
+ // ripple.mount(element)
139
+
140
+ // // Simulate mousedown event
141
+ // const mouseEvent = {
142
+ // type: 'mousedown',
143
+ // offsetX: 10,
144
+ // offsetY: 20,
145
+ // target: element
146
+ // }
147
+ // element.__handlers.mousedown[0](mouseEvent)
148
+
149
+ // // Verify custom config was applied
150
+ // expect(capturedRipple.style.transition).toContain(`${customConfig.duration}ms`)
151
+ // expect(capturedRipple.style.transition).toContain(customConfig.timing)
152
+ // expect(capturedRipple.style.opacity).toBe(customConfig.opacity[0])
153
+
154
+ // // Force reflow simulation
155
+ // capturedRipple.offsetHeight
156
+
157
+ // // Check end opacity is applied after animation
158
+ // expect(capturedRipple.style.opacity).toBe(customConfig.opacity[1])
159
+ // })
160
+
161
+ test('should not fail when unmounting a null element', () => {
162
+ const ripple = createRipple()
163
+ expect(() => ripple.unmount(null)).not.toThrow()
164
+ })
165
+ })
package/test/setup.js ADDED
@@ -0,0 +1,458 @@
1
+ // test/setup.js
2
+ // Setup global DOM environment for testing
3
+
4
+ // Mock the document and window objects for DOM testing
5
+ // test/setup.js
6
+
7
+ // Mock DOM environment for testing
8
+ class MockElement {
9
+ constructor (tagName) {
10
+ this.tagName = tagName.toUpperCase()
11
+ this.className = ''
12
+ this.style = {}
13
+ this.attributes = {}
14
+ this.children = []
15
+ this.eventListeners = {}
16
+ this.innerHTML = ''
17
+ this.textContent = ''
18
+ this.dataset = {}
19
+ }
20
+
21
+ appendChild (child) {
22
+ this.children.push(child)
23
+ return child
24
+ }
25
+
26
+ insertBefore (newChild, referenceChild) {
27
+ const index = referenceChild ? this.children.indexOf(referenceChild) : 0
28
+ this.children.splice(index, 0, newChild)
29
+ return newChild
30
+ }
31
+
32
+ removeChild (child) {
33
+ const index = this.children.indexOf(child)
34
+ if (index !== -1) {
35
+ this.children.splice(index, 1)
36
+ }
37
+ return child
38
+ }
39
+
40
+ getAttribute (name) {
41
+ return this.attributes[name]
42
+ }
43
+
44
+ setAttribute (name, value) {
45
+ this.attributes[name] = value
46
+ }
47
+
48
+ removeAttribute (name) {
49
+ delete this.attributes[name]
50
+ }
51
+
52
+ hasAttribute (name) {
53
+ return name in this.attributes
54
+ }
55
+
56
+ querySelector (selector) {
57
+ // Super simple selector matching for testing
58
+ if (selector.startsWith('.')) {
59
+ const className = selector.substring(1)
60
+ return this.getElementsByClassName(className)[0] || null
61
+ }
62
+ return null
63
+ }
64
+
65
+ querySelectorAll (selector) {
66
+ if (selector.startsWith('.')) {
67
+ return this.getElementsByClassName(selector.substring(1))
68
+ }
69
+ return []
70
+ }
71
+
72
+ getElementsByClassName (className) {
73
+ const results = []
74
+ if (this.className.split(' ').includes(className)) {
75
+ results.push(this)
76
+ }
77
+ this.children.forEach(child => {
78
+ if (child.getElementsByClassName) {
79
+ results.push(...child.getElementsByClassName(className))
80
+ }
81
+ })
82
+ return results
83
+ }
84
+
85
+ addEventListener (type, listener) {
86
+ if (!this.eventListeners[type]) {
87
+ this.eventListeners[type] = []
88
+ }
89
+ this.eventListeners[type].push(listener)
90
+ }
91
+
92
+ removeEventListener (type, listener) {
93
+ if (this.eventListeners[type]) {
94
+ this.eventListeners[type] = this.eventListeners[type]
95
+ .filter(l => l !== listener)
96
+ }
97
+ }
98
+
99
+ dispatchEvent (event) {
100
+ if (this.eventListeners[event.type]) {
101
+ this.eventListeners[event.type].forEach(listener => {
102
+ listener(event)
103
+ })
104
+ }
105
+ return !event.defaultPrevented
106
+ }
107
+
108
+ get classList () {
109
+ const classNames = this.className.split(' ').filter(Boolean)
110
+ return {
111
+ add: (...classes) => {
112
+ classes.forEach(c => {
113
+ if (!classNames.includes(c)) {
114
+ classNames.push(c)
115
+ }
116
+ })
117
+ this.className = classNames.join(' ')
118
+ },
119
+ remove: (...classes) => {
120
+ classes.forEach(c => {
121
+ const index = classNames.indexOf(c)
122
+ if (index !== -1) {
123
+ classNames.splice(index, 1)
124
+ }
125
+ })
126
+ this.className = classNames.join(' ')
127
+ },
128
+ toggle: (c) => {
129
+ const index = classNames.indexOf(c)
130
+ if (index !== -1) {
131
+ classNames.splice(index, 1)
132
+ } else {
133
+ classNames.push(c)
134
+ }
135
+ this.className = classNames.join(' ')
136
+ return index === -1
137
+ },
138
+ contains: (c) => classNames.includes(c)
139
+ }
140
+ }
141
+
142
+ getBoundingClientRect () {
143
+ return {
144
+ width: 100,
145
+ height: 50,
146
+ top: 0,
147
+ left: 0,
148
+ right: 100,
149
+ bottom: 50
150
+ }
151
+ }
152
+
153
+ remove () {
154
+ if (this.parentNode) {
155
+ this.parentNode.removeChild(this)
156
+ }
157
+ }
158
+ }
159
+
160
+ // Set up global document object for tests
161
+ global.document = {
162
+ createElement: (tag) => new MockElement(tag),
163
+ createDocumentFragment: () => new MockElement('fragment'),
164
+ body: new MockElement('body'),
165
+ eventListeners: {},
166
+ addEventListener: function (type, listener) {
167
+ if (!this.eventListeners[type]) {
168
+ this.eventListeners[type] = []
169
+ }
170
+ this.eventListeners[type].push(listener)
171
+ },
172
+ removeEventListener: function (type, listener) {
173
+ if (this.eventListeners[type]) {
174
+ this.eventListeners[type] = this.eventListeners[type]
175
+ .filter(l => l !== listener)
176
+ }
177
+ },
178
+ dispatchEvent: function (event) {
179
+ if (this.eventListeners[event.type]) {
180
+ this.eventListeners[event.type].forEach(listener => {
181
+ listener(event)
182
+ })
183
+ }
184
+ return !event.defaultPrevented
185
+ }
186
+ }
187
+
188
+ // Set up global window object
189
+ global.window = {
190
+ getComputedStyle: () => ({
191
+ position: 'static'
192
+ })
193
+ }
194
+
195
+ // Set up Event constructor
196
+ global.Event = class Event {
197
+ constructor (type) {
198
+ this.type = type
199
+ this.defaultPrevented = false
200
+ }
201
+
202
+ preventDefault () {
203
+ this.defaultPrevented = true
204
+ }
205
+ }
206
+
207
+ // Set up AbortController
208
+ global.AbortController = class AbortController {
209
+ constructor () {
210
+ this.signal = { aborted: false }
211
+ }
212
+
213
+ abort () {
214
+ this.signal.aborted = true
215
+ }
216
+ }
217
+
218
+ // Additional DOM setup if needed
219
+
220
+ global.document = {
221
+ createElement: (tag) => {
222
+ const element = {
223
+ tagName: tag.toUpperCase(),
224
+ classList: {
225
+ add: (...classes) => {
226
+ element.className = (element.className || '').split(' ')
227
+ .concat(classes)
228
+ .filter(Boolean)
229
+ .join(' ')
230
+ },
231
+ remove: (...classes) => {
232
+ if (!element.className) return
233
+ const currentClasses = element.className.split(' ')
234
+ element.className = currentClasses
235
+ .filter(cls => !classes.includes(cls))
236
+ .join(' ')
237
+ },
238
+ toggle: (cls) => {
239
+ if (!element.className) {
240
+ element.className = cls
241
+ return true
242
+ }
243
+
244
+ const currentClasses = element.className.split(' ')
245
+ const hasClass = currentClasses.includes(cls)
246
+
247
+ if (hasClass) {
248
+ element.className = currentClasses
249
+ .filter(c => c !== cls)
250
+ .join(' ')
251
+ return false
252
+ } else {
253
+ element.className = [...currentClasses, cls]
254
+ .filter(Boolean)
255
+ .join(' ')
256
+ return true
257
+ }
258
+ },
259
+ contains: (cls) => {
260
+ if (!element.className) return false
261
+ return element.className.split(' ').includes(cls)
262
+ },
263
+ toString: () => element.className || ''
264
+ },
265
+ style: {},
266
+ dataset: {},
267
+ attributes: {},
268
+ children: [],
269
+ childNodes: [],
270
+ innerHTML: '',
271
+ textContent: '',
272
+ appendChild: (child) => {
273
+ element.children.push(child)
274
+ element.childNodes.push(child)
275
+ child.parentNode = element
276
+ return child
277
+ },
278
+ insertBefore: (newChild, refChild) => {
279
+ const index = refChild ? element.children.indexOf(refChild) : 0
280
+ if (index === -1) {
281
+ element.children.push(newChild)
282
+ } else {
283
+ element.children.splice(index, 0, newChild)
284
+ }
285
+ element.childNodes = [...element.children]
286
+ newChild.parentNode = element
287
+ return newChild
288
+ },
289
+ removeChild: (child) => {
290
+ const index = element.children.indexOf(child)
291
+ if (index !== -1) {
292
+ element.children.splice(index, 1)
293
+ element.childNodes = [...element.children]
294
+ }
295
+ return child
296
+ },
297
+ remove: () => {
298
+ if (element.parentNode) {
299
+ element.parentNode.removeChild(element)
300
+ }
301
+ },
302
+ querySelector: (selector) => {
303
+ // Very basic selector implementation - only supports class selectors for now
304
+ if (selector.startsWith('.')) {
305
+ const className = selector.slice(1)
306
+ return element.children.find(child =>
307
+ child.className && child.className.split(' ').includes(className)
308
+ )
309
+ }
310
+ return null
311
+ },
312
+ querySelectorAll: (selector) => {
313
+ // Very basic selector implementation for tests
314
+ if (selector.startsWith('.')) {
315
+ const className = selector.slice(1)
316
+ return element.children.filter(child =>
317
+ child.className && child.className.split(' ').includes(className)
318
+ )
319
+ }
320
+ return []
321
+ },
322
+ getBoundingClientRect: () => ({
323
+ width: 100,
324
+ height: 50,
325
+ top: 0,
326
+ left: 0,
327
+ right: 100,
328
+ bottom: 50
329
+ }),
330
+ setAttribute: (name, value) => {
331
+ element.attributes[name] = value
332
+ if (name === 'class') element.className = value
333
+ },
334
+ removeAttribute: (name) => {
335
+ delete element.attributes[name]
336
+ if (name === 'class') element.className = ''
337
+ },
338
+ getAttribute: (name) => element.attributes[name] || null,
339
+ hasAttribute: (name) => name in element.attributes,
340
+ addEventListener: (event, handler) => {
341
+ element.__handlers = element.__handlers || {}
342
+ element.__handlers[event] = element.__handlers[event] || []
343
+ element.__handlers[event].push(handler)
344
+ },
345
+ removeEventListener: (event, handler) => {
346
+ if (!element.__handlers?.[event]) return
347
+ element.__handlers[event] = element.__handlers[event].filter(h => h !== handler)
348
+ },
349
+ dispatchEvent: (event) => {
350
+ if (!element.__handlers?.[event.type]) return true
351
+ element.__handlers[event.type].forEach(handler => handler(event))
352
+ return !event.defaultPrevented
353
+ }
354
+ }
355
+ return element
356
+ },
357
+ createDocumentFragment: () => {
358
+ return {
359
+ children: [],
360
+ childNodes: [],
361
+ appendChild: function (child) {
362
+ this.children.push(child)
363
+ this.childNodes.push(child)
364
+ child.parentNode = this
365
+ return child
366
+ },
367
+ hasChildNodes: function () {
368
+ return this.childNodes.length > 0
369
+ },
370
+ querySelector: () => null,
371
+ querySelectorAll: () => []
372
+ }
373
+ },
374
+ body: {
375
+ appendChild: () => {},
376
+ classList: {
377
+ add: () => {},
378
+ remove: () => {},
379
+ contains: () => false
380
+ },
381
+ dispatchEvent: () => true,
382
+ getAttribute: () => null,
383
+ setAttribute: () => {}
384
+ }
385
+ }
386
+
387
+ global.window = {
388
+ getComputedStyle: () => ({
389
+ position: 'static',
390
+ getPropertyValue: () => ''
391
+ }),
392
+ addEventListener: () => {},
393
+ removeEventListener: () => {},
394
+ dispatchEvent: () => {},
395
+ innerWidth: 1024,
396
+ innerHeight: 768,
397
+ history: {
398
+ pushState: () => {}
399
+ },
400
+ location: {
401
+ pathname: '/'
402
+ },
403
+ navigator: {
404
+ userAgent: 'test'
405
+ },
406
+ performance: {
407
+ now: () => Date.now()
408
+ },
409
+ localStorage: {
410
+ getItem: () => null,
411
+ setItem: () => {},
412
+ removeItem: () => {}
413
+ }
414
+ }
415
+
416
+ // Mock for CustomEvent
417
+ global.CustomEvent = class CustomEvent {
418
+ constructor (type, options = {}) {
419
+ this.type = type
420
+ this.detail = options.detail || {}
421
+ this.defaultPrevented = false
422
+ }
423
+
424
+ preventDefault () {
425
+ this.defaultPrevented = true
426
+ }
427
+ }
428
+
429
+ global.Event = class Event {
430
+ constructor (type) {
431
+ this.type = type
432
+ this.defaultPrevented = false
433
+ }
434
+
435
+ preventDefault () {
436
+ this.defaultPrevented = true
437
+ }
438
+ }
439
+
440
+ // Mock console methods to prevent test output pollution
441
+ const originalConsole = { ...console }
442
+ global.console = {
443
+ ...console,
444
+ log: (...args) => {
445
+ if (process.env.DEBUG) {
446
+ originalConsole.log(...args)
447
+ }
448
+ },
449
+ warn: (...args) => {
450
+ if (process.env.DEBUG) {
451
+ originalConsole.warn(...args)
452
+ }
453
+ },
454
+ error: (...args) => {
455
+ // Always log errors
456
+ originalConsole.error(...args)
457
+ }
458
+ }