mtrl 0.0.0 → 0.0.1

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
+ }
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;
@@ -1,9 +1,31 @@
1
1
  // src/styles/abstract/_mixins.scss
2
2
  @use 'sass:map';
3
3
  @use 'sass:list';
4
+ @use 'sass:math';
4
5
  @use 'variables' as v;
5
6
  @use 'functions' as f;
6
7
 
8
+ // Common icons map
9
+ $icons: (
10
+ 'chevron_right': 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpolyline points="9 18 15 12 9 6"%3E%3C/polyline%3E%3C/svg%3E',
11
+ 'chevron_down': 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpolyline points="6 9 12 15 18 9"%3E%3C/polyline%3E%3C/svg%3E',
12
+ 'check': 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"%3E%3Cpolyline points="20 6 9 17 4 12"%3E%3C/polyline%3E%3C/svg%3E'
13
+ );
14
+
15
+ @mixin icon($name, $size: 24px) {
16
+ $icon-url: map.get($icons, $name);
17
+ @if $icon-url {
18
+ content: '';
19
+ display: inline-block;
20
+ width: $size;
21
+ height: $size;
22
+ background-image: url($icon-url);
23
+ background-size: contain;
24
+ background-position: center;
25
+ background-repeat: no-repeat;
26
+ }
27
+ }
28
+
7
29
  // Typography
8
30
  @mixin typography($scale) {
9
31
  $styles: f.get-typography($scale);
@@ -253,6 +275,59 @@
253
275
  }
254
276
  }
255
277
 
278
+
279
+ // Scrollbar mixin for consistent styling across components
280
+ @mixin scrollbar(
281
+ $width: 8px,
282
+ $track-color: rgba(0, 0, 0, 0.05),
283
+ $thumb-color: rgba(0, 0, 0, 0.2)
284
+ ) {
285
+ &::-webkit-scrollbar {
286
+ width: $width;
287
+ }
288
+
289
+ &::-webkit-scrollbar-track {
290
+ background: $track-color;
291
+ }
292
+
293
+ &::-webkit-scrollbar-thumb {
294
+ background: $thumb-color;
295
+ border-radius: math.div($width, 2);
296
+ }
297
+
298
+ // Firefox scrollbar (future compatibility)
299
+ scrollbar-width: thin;
300
+ scrollbar-color: $thumb-color $track-color;
301
+ }
302
+
303
+
304
+ // Flexbox layout mixins
305
+ @mixin flex-row($align: center, $justify: flex-start, $gap: 0) {
306
+ display: flex;
307
+ flex-direction: row;
308
+ align-items: $align;
309
+ justify-content: $justify;
310
+ @if $gap > 0 {
311
+ gap: $gap;
312
+ }
313
+ }
314
+
315
+ @mixin flex-column($align: flex-start, $justify: flex-start, $gap: 0) {
316
+ display: flex;
317
+ flex-direction: column;
318
+ align-items: $align;
319
+ justify-content: $justify;
320
+ @if $gap > 0 {
321
+ gap: $gap;
322
+ }
323
+ }
324
+
325
+ @mixin flex-center {
326
+ display: flex;
327
+ align-items: center;
328
+ justify-content: center;
329
+ }
330
+
256
331
  // Print
257
332
  @mixin print {
258
333
  @media print {
@@ -151,6 +151,7 @@ $z-index: (
151
151
  'dialog': 700,
152
152
  'dropdown': 600,
153
153
  'tooltip': 500,
154
+ 'menu': 700,
154
155
  'sticky': 100,
155
156
  'fixed': 50,
156
157
  'default': 1,