mtrl 0.0.0 → 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/CONTRIBUTING.md +179 -0
- package/README.md +2 -0
- package/TESTING.md +104 -0
- package/index.js +3 -2
- package/package.json +5 -2
- package/src/components/button/config.js +3 -1
- package/src/components/menu/api.js +117 -0
- package/src/components/menu/constants.js +42 -0
- package/src/components/menu/features/items-manager.js +371 -0
- package/src/components/menu/features/keyboard-navigation.js +129 -0
- package/src/components/menu/features/positioning.js +125 -0
- package/src/components/menu/features/visibility.js +179 -0
- package/src/components/menu/index.js +2 -0
- package/src/components/menu/menu-item.js +41 -0
- package/src/components/menu/menu.js +54 -0
- package/src/components/menu/styles.scss +150 -0
- package/src/core/build/ripple.js +76 -9
- package/src/core/compose/features/disabled.js +38 -19
- package/src/core/state/disabled.js +41 -4
- package/src/index.js +4 -3
- package/src/styles/abstract/_config.scss +1 -1
- package/src/styles/abstract/_mixins.scss +75 -0
- package/src/styles/abstract/_variables.scss +1 -0
- package/test/components/button.test.js +158 -0
- package/test/core/emitter.test.js +141 -0
- package/test/core/ripple.test.js +165 -0
- package/test/setup.js +458 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// src/components/menu/features/visibility.js
|
|
2
|
+
import { MENU_EVENTS } from '../constants'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adds visibility management functionality to a menu component
|
|
6
|
+
* @param {Object} config - Menu configuration
|
|
7
|
+
* @param {string} config.prefix - CSS class prefix
|
|
8
|
+
* @returns {Function} Component enhancer
|
|
9
|
+
*/
|
|
10
|
+
export const withVisibility = (config) => (component) => {
|
|
11
|
+
let isVisible = false
|
|
12
|
+
let outsideClickHandler = null
|
|
13
|
+
let keydownHandler = null
|
|
14
|
+
|
|
15
|
+
// Create the component interface with hide/show methods first
|
|
16
|
+
const enhancedComponent = {
|
|
17
|
+
...component,
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shows the menu
|
|
21
|
+
*/
|
|
22
|
+
show () {
|
|
23
|
+
if (isVisible) return this
|
|
24
|
+
|
|
25
|
+
// First set visibility to true to prevent multiple calls
|
|
26
|
+
isVisible = true
|
|
27
|
+
|
|
28
|
+
// Make sure the element is in the DOM
|
|
29
|
+
if (!component.element.parentNode) {
|
|
30
|
+
document.body.appendChild(component.element)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Always clean up previous handlers before adding new ones
|
|
34
|
+
if (outsideClickHandler) {
|
|
35
|
+
document.removeEventListener('mousedown', outsideClickHandler)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Setup outside click handler for closing
|
|
39
|
+
outsideClickHandler = handleOutsideClick
|
|
40
|
+
|
|
41
|
+
// Use setTimeout to ensure the handler is not triggered immediately
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
document.addEventListener('mousedown', outsideClickHandler)
|
|
44
|
+
}, 0)
|
|
45
|
+
|
|
46
|
+
// Setup keyboard navigation
|
|
47
|
+
if (!keydownHandler) {
|
|
48
|
+
keydownHandler = handleKeydown
|
|
49
|
+
document.addEventListener('keydown', keydownHandler)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Add display block first for transition to work
|
|
53
|
+
component.element.style.display = 'block'
|
|
54
|
+
|
|
55
|
+
// Force a reflow before adding the visible class for animation
|
|
56
|
+
component.element.offsetHeight
|
|
57
|
+
component.element.classList.add(`${config.prefix}-menu--visible`)
|
|
58
|
+
component.element.setAttribute('aria-hidden', 'false')
|
|
59
|
+
|
|
60
|
+
// Emit open event
|
|
61
|
+
component.emit(MENU_EVENTS.OPEN)
|
|
62
|
+
|
|
63
|
+
return this
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Hides the menu
|
|
68
|
+
*/
|
|
69
|
+
hide () {
|
|
70
|
+
// Return early if already hidden
|
|
71
|
+
if (!isVisible) return this
|
|
72
|
+
|
|
73
|
+
// First set the visibility flag to false
|
|
74
|
+
isVisible = false
|
|
75
|
+
|
|
76
|
+
// Close any open submenus first
|
|
77
|
+
if (component.closeSubmenus) {
|
|
78
|
+
component.closeSubmenus()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Remove ALL event listeners
|
|
82
|
+
if (outsideClickHandler) {
|
|
83
|
+
document.removeEventListener('mousedown', outsideClickHandler)
|
|
84
|
+
outsideClickHandler = null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (keydownHandler) {
|
|
88
|
+
document.removeEventListener('keydown', keydownHandler)
|
|
89
|
+
keydownHandler = null
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Hide the menu with visual indication first
|
|
93
|
+
component.element.classList.remove(`${config.prefix}-menu--visible`)
|
|
94
|
+
component.element.setAttribute('aria-hidden', 'true')
|
|
95
|
+
|
|
96
|
+
// Define a reliable cleanup function
|
|
97
|
+
const cleanupElement = () => {
|
|
98
|
+
// Safety check to prevent errors
|
|
99
|
+
if (component.element) {
|
|
100
|
+
component.element.style.display = 'none'
|
|
101
|
+
|
|
102
|
+
// Remove from DOM if still attached
|
|
103
|
+
if (component.element.parentNode) {
|
|
104
|
+
component.element.remove()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Try to use transition end for smooth animation
|
|
110
|
+
const handleTransitionEnd = (e) => {
|
|
111
|
+
if (e.propertyName === 'opacity' || e.propertyName === 'transform') {
|
|
112
|
+
component.element.removeEventListener('transitionend', handleTransitionEnd)
|
|
113
|
+
cleanupElement()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
component.element.addEventListener('transitionend', handleTransitionEnd)
|
|
118
|
+
|
|
119
|
+
// Fallback timeout in case transition events don't fire
|
|
120
|
+
// This ensures the menu always gets removed
|
|
121
|
+
setTimeout(cleanupElement, 300)
|
|
122
|
+
|
|
123
|
+
// Emit close event
|
|
124
|
+
component.emit(MENU_EVENTS.CLOSE)
|
|
125
|
+
|
|
126
|
+
return this
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Returns whether the menu is currently visible
|
|
131
|
+
* @returns {boolean} Visibility state
|
|
132
|
+
*/
|
|
133
|
+
isVisible () {
|
|
134
|
+
return isVisible
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Handles clicks outside the menu
|
|
140
|
+
* @param {MouseEvent} event - Mouse event
|
|
141
|
+
*/
|
|
142
|
+
const handleOutsideClick = (event) => {
|
|
143
|
+
if (!isVisible) return
|
|
144
|
+
|
|
145
|
+
// Store the opening button if available
|
|
146
|
+
const openingButton = config.openingButton?.element
|
|
147
|
+
|
|
148
|
+
// Check if click is outside the menu but not on the opening button
|
|
149
|
+
const clickedElement = event.target
|
|
150
|
+
|
|
151
|
+
// Don't close if the click is inside the menu
|
|
152
|
+
if (component.element.contains(clickedElement)) {
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Don't close if the click is on the opening button (it will handle opening/closing)
|
|
157
|
+
if (openingButton && (openingButton === clickedElement || openingButton.contains(clickedElement))) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// If we got here, close the menu
|
|
162
|
+
enhancedComponent.hide()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Handles keyboard events
|
|
167
|
+
* @param {KeyboardEvent} event - Keyboard event
|
|
168
|
+
*/
|
|
169
|
+
const handleKeydown = (event) => {
|
|
170
|
+
if (!isVisible) return
|
|
171
|
+
|
|
172
|
+
if (event.key === 'Escape') {
|
|
173
|
+
event.preventDefault()
|
|
174
|
+
enhancedComponent.hide()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return enhancedComponent
|
|
179
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/components/menu/menu-item.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a menu item element
|
|
5
|
+
* @param {Object} itemConfig - Item configuration
|
|
6
|
+
* @param {string} prefix - CSS class prefix
|
|
7
|
+
* @returns {HTMLElement} Menu item element
|
|
8
|
+
*/
|
|
9
|
+
export const createMenuItem = (itemConfig, prefix) => {
|
|
10
|
+
const item = document.createElement('li')
|
|
11
|
+
item.className = `${prefix}-menu-item`
|
|
12
|
+
|
|
13
|
+
if (itemConfig.type === 'divider') {
|
|
14
|
+
item.className = `${prefix}-menu-divider`
|
|
15
|
+
return item
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (itemConfig.class) {
|
|
19
|
+
item.className += ` ${itemConfig.class}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (itemConfig.disabled) {
|
|
23
|
+
item.setAttribute('aria-disabled', 'true')
|
|
24
|
+
item.className += ` ${prefix}-menu-item--disabled`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (itemConfig.name) {
|
|
28
|
+
item.setAttribute('data-name', itemConfig.name)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
item.textContent = itemConfig.text || ''
|
|
32
|
+
|
|
33
|
+
if (itemConfig.items?.length) {
|
|
34
|
+
item.className += ` ${prefix}-menu-item--submenu`
|
|
35
|
+
item.setAttribute('aria-haspopup', 'true')
|
|
36
|
+
item.setAttribute('aria-expanded', 'false')
|
|
37
|
+
// We don't need to add a submenu indicator as it's handled by CSS ::after
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return item
|
|
41
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/components/menu/menu.js
|
|
2
|
+
|
|
3
|
+
import { PREFIX } from '../../core/config'
|
|
4
|
+
import { pipe } from '../../core/compose'
|
|
5
|
+
import { createBase, withElement } from '../../core/compose/component'
|
|
6
|
+
import { withEvents, withLifecycle } from '../../core/compose/features'
|
|
7
|
+
import { withAPI } from './api'
|
|
8
|
+
import { withVisibility } from './features/visibility'
|
|
9
|
+
import { withItemsManager } from './features/items-manager'
|
|
10
|
+
import { withPositioning } from './features/positioning'
|
|
11
|
+
import { withKeyboardNavigation } from './features/keyboard-navigation'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Creates a new Menu component
|
|
15
|
+
* @param {Object} config - Menu configuration
|
|
16
|
+
* @param {Array} [config.items] - Menu items
|
|
17
|
+
* @param {string} [config.class] - Additional CSS classes
|
|
18
|
+
* @param {HTMLElement} [config.target] - Target element for positioning
|
|
19
|
+
* @param {boolean} [config.stayOpenOnSelect] - Whether to keep the menu open after an item is selected
|
|
20
|
+
* @param {HTMLElement} [config.openingButton] - Button that opens the menu
|
|
21
|
+
* @returns {Object} Menu component instance
|
|
22
|
+
*/
|
|
23
|
+
const createMenu = (config = {}) => {
|
|
24
|
+
const baseConfig = {
|
|
25
|
+
...config,
|
|
26
|
+
componentName: 'menu',
|
|
27
|
+
prefix: PREFIX
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return pipe(
|
|
31
|
+
createBase,
|
|
32
|
+
withEvents(),
|
|
33
|
+
withElement({
|
|
34
|
+
tag: 'div',
|
|
35
|
+
componentName: 'menu',
|
|
36
|
+
className: config.class,
|
|
37
|
+
attrs: {
|
|
38
|
+
role: 'menu',
|
|
39
|
+
tabindex: '-1',
|
|
40
|
+
'aria-hidden': 'true'
|
|
41
|
+
}
|
|
42
|
+
}),
|
|
43
|
+
withLifecycle(),
|
|
44
|
+
withItemsManager(baseConfig),
|
|
45
|
+
withVisibility(baseConfig),
|
|
46
|
+
withPositioning,
|
|
47
|
+
withKeyboardNavigation(baseConfig),
|
|
48
|
+
comp => withAPI({
|
|
49
|
+
lifecycle: comp.lifecycle
|
|
50
|
+
})(comp)
|
|
51
|
+
)(baseConfig)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default createMenu
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// src/components/menu/styles.scss
|
|
2
|
+
@use 'sass:map';
|
|
3
|
+
@use '../../styles/abstract/config' as c;
|
|
4
|
+
@use '../../styles/abstract/mixins' as m;
|
|
5
|
+
@use '../../styles/abstract/variables' as v;
|
|
6
|
+
|
|
7
|
+
.#{c.$prefix}-menu {
|
|
8
|
+
// Base styles
|
|
9
|
+
@include c.typography('body-medium');
|
|
10
|
+
@include c.shape('small');
|
|
11
|
+
|
|
12
|
+
position: fixed;
|
|
13
|
+
z-index: map.get(v.$z-index, 'menu');
|
|
14
|
+
min-width: 112px;
|
|
15
|
+
max-width: 280px;
|
|
16
|
+
padding: 8px 0;
|
|
17
|
+
background-color: var(--mtrl-sys-color-surface-container);
|
|
18
|
+
color: var(--mtrl-sys-color-on-surface);
|
|
19
|
+
@include c.elevation(2);
|
|
20
|
+
|
|
21
|
+
display: none;
|
|
22
|
+
opacity: 0;
|
|
23
|
+
transform: scale(0.8);
|
|
24
|
+
transform-origin: top left;
|
|
25
|
+
pointer-events: none;
|
|
26
|
+
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
|
27
|
+
transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
28
|
+
|
|
29
|
+
&--visible {
|
|
30
|
+
display: block;
|
|
31
|
+
opacity: 1;
|
|
32
|
+
transform: scale(1);
|
|
33
|
+
pointer-events: auto;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
&--submenu {
|
|
37
|
+
position: absolute;
|
|
38
|
+
z-index: map.get(v.$z-index, 'menu') + 1;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// List container
|
|
42
|
+
&-list {
|
|
43
|
+
margin: 0;
|
|
44
|
+
padding: 0;
|
|
45
|
+
list-style: none;
|
|
46
|
+
overflow-y: auto;
|
|
47
|
+
max-height: calc(100vh - 96px);
|
|
48
|
+
@include m.scrollbar;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Menu items
|
|
52
|
+
&-item {
|
|
53
|
+
@include c.typography('body-large');
|
|
54
|
+
@include m.flex-row;
|
|
55
|
+
|
|
56
|
+
position: relative;
|
|
57
|
+
min-height: 48px;
|
|
58
|
+
padding: 12px 16px;
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
user-select: none;
|
|
61
|
+
color: var(--mtrl-sys-color-on-surface);
|
|
62
|
+
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
63
|
+
|
|
64
|
+
&:hover {
|
|
65
|
+
@include c.state-layer(var(--mtrl-sys-color-on-surface), 'hover');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
&:focus {
|
|
69
|
+
@include c.state-layer(var(--mtrl-sys-color-on-surface), 'focus');
|
|
70
|
+
outline: none;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
&:active {
|
|
74
|
+
@include c.state-layer(var(--mtrl-sys-color-on-surface), 'pressed');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Submenu indicator
|
|
78
|
+
&--submenu {
|
|
79
|
+
padding-right: 48px;
|
|
80
|
+
|
|
81
|
+
&::after {
|
|
82
|
+
@include m.icon('chevron_right');
|
|
83
|
+
position: absolute;
|
|
84
|
+
right: 16px;
|
|
85
|
+
top: 50%;
|
|
86
|
+
transform: translateY(-50%);
|
|
87
|
+
opacity: 0.87;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
&[aria-expanded="true"] {
|
|
91
|
+
@include c.state-layer(var(--mtrl-sys-color-on-surface), 'hover');
|
|
92
|
+
|
|
93
|
+
&::after {
|
|
94
|
+
opacity: 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Disabled state
|
|
100
|
+
&--disabled {
|
|
101
|
+
pointer-events: none;
|
|
102
|
+
color: rgba(var(--mtrl-sys-color-on-surface-rgb), 0.38);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Divider
|
|
107
|
+
&-divider {
|
|
108
|
+
height: 1px;
|
|
109
|
+
margin: 8px 0;
|
|
110
|
+
background-color: var(--mtrl-sys-color-outline-variant);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Accessibility
|
|
114
|
+
@include c.focus-ring('.#{c.$prefix}-menu-item:focus-visible');
|
|
115
|
+
|
|
116
|
+
@include c.reduced-motion {
|
|
117
|
+
transition: none;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@include c.high-contrast {
|
|
121
|
+
border: 1px solid CurrentColor;
|
|
122
|
+
|
|
123
|
+
.#{c.$prefix}-menu-divider {
|
|
124
|
+
background-color: CurrentColor;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.#{c.$prefix}-menu-item--disabled {
|
|
128
|
+
opacity: 1;
|
|
129
|
+
color: GrayText;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// RTL Support
|
|
134
|
+
@include c.rtl {
|
|
135
|
+
transform-origin: top right;
|
|
136
|
+
|
|
137
|
+
.#{c.$prefix}-menu-item {
|
|
138
|
+
&--submenu {
|
|
139
|
+
padding-right: 16px;
|
|
140
|
+
padding-left: 48px;
|
|
141
|
+
|
|
142
|
+
&::after {
|
|
143
|
+
right: auto;
|
|
144
|
+
left: 16px;
|
|
145
|
+
transform: translateY(-50%) rotate(180deg);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
package/src/core/build/ripple.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
}
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/index.js
CHANGED
|
@@ -3,9 +3,10 @@ export { createElement } from './core/dom/create'
|
|
|
3
3
|
export { default as createLayout } from './core/layout'
|
|
4
4
|
export { default as createButton } from './components/button'
|
|
5
5
|
export { default as createCheckbox } from './components/checkbox'
|
|
6
|
-
export { default as createTextfield } from './components/textfield'
|
|
7
|
-
export { default as createSwitch } from './components/switch'
|
|
8
6
|
export { default as createContainer } from './components/container'
|
|
9
|
-
export { default as
|
|
7
|
+
export { default as createMenu } from './components/menu'
|
|
10
8
|
export { default as createNavigation } from './components/navigation'
|
|
9
|
+
export { default as createSnackbar } from './components/snackbar'
|
|
10
|
+
export { default as createSwitch } from './components/switch'
|
|
11
|
+
export { default as createTextfield } from './components/textfield'
|
|
11
12
|
export { default as createList } from './components/list'
|