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.
@@ -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,2 @@
1
+ // src/components/menu/index.js
2
+ export { default } from './menu.js'
@@ -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
+ }
@@ -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
+ }
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 createSnackbar } from './components/snackbar'
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'
@@ -25,4 +25,4 @@
25
25
  rtl,
26
26
  touch-target,
27
27
  custom-scrollbar,
28
- print;
28
+ print;