mtrl 0.2.3 → 0.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtrl",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "description": "A functional JavaScript component library with composable architecture based on Material Design 3",
5
5
  "keywords": [
6
6
  "component",
@@ -0,0 +1,79 @@
1
+ // src/components/ripple/_ripple.scss
2
+ @use '../../styles/abstract/base' as base;
3
+ @use '../../styles/abstract/variables' as v;
4
+ @use '../../styles/abstract/functions' as f;
5
+ @use '../../styles/abstract/mixins' as m;
6
+ @use '../../styles/abstract/theme' as t;
7
+
8
+ $component: '#{base.$prefix}-ripple';
9
+
10
+ .#{$component} {
11
+ // Ripple container
12
+ position: absolute;
13
+ top: 0;
14
+ left: 0;
15
+ right: 0;
16
+ bottom: 0;
17
+ overflow: hidden;
18
+ border-radius: inherit;
19
+ pointer-events: none;
20
+ z-index: 0;
21
+
22
+ // Ripple element
23
+ &-wave {
24
+ position: absolute;
25
+ border-radius: 50%;
26
+ background-color: currentColor;
27
+ transform: scale(0);
28
+ opacity: 0;
29
+ pointer-events: none;
30
+ will-change: transform, opacity;
31
+
32
+ // Animation
33
+ transition-property: transform, opacity;
34
+ transition-duration: v.motion('duration-short4');
35
+ transition-timing-function: v.motion('easing-standard');
36
+
37
+ // Active ripple
38
+ &.active {
39
+ transform: scale(1);
40
+ opacity: v.state('hover-state-layer-opacity');
41
+ }
42
+
43
+ &.fade-out {
44
+ opacity: 0;
45
+ }
46
+ }
47
+ }
48
+
49
+ // Standalone utility for adding ripple to any element
50
+ [data-ripple] {
51
+ position: relative;
52
+ overflow: hidden;
53
+
54
+ &::after {
55
+ content: '';
56
+ position: absolute;
57
+ top: 0;
58
+ left: 0;
59
+ right: 0;
60
+ bottom: 0;
61
+ z-index: 0;
62
+ pointer-events: none;
63
+ }
64
+
65
+ // Handle ripple color based on data attribute
66
+ &[data-ripple="light"]::after {
67
+ background-color: rgba(255, 255, 255, 0.3);
68
+ }
69
+
70
+ &[data-ripple="dark"]::after {
71
+ background-color: rgba(0, 0, 0, 0.1);
72
+ }
73
+
74
+ // Make content appear above ripple
75
+ > * {
76
+ position: relative;
77
+ z-index: 1;
78
+ }
79
+ }
@@ -0,0 +1,48 @@
1
+ // src/core/build/constants.ts
2
+
3
+ /**
4
+ * Animation timing functions for ripple effect
5
+ */
6
+ export enum RIPPLE_TIMING {
7
+ LINEAR = 'linear',
8
+ EASE = 'ease',
9
+ EASE_IN = 'ease-in',
10
+ EASE_OUT = 'ease-out',
11
+ EASE_IN_OUT = 'ease-in-out',
12
+ MATERIAL = 'cubic-bezier(0.4, 0.0, 0.2, 1)'
13
+ }
14
+
15
+ /**
16
+ * Default configuration for ripple effect
17
+ */
18
+ export const RIPPLE_CONFIG = {
19
+ duration: 375,
20
+ timing: RIPPLE_TIMING.LINEAR,
21
+ opacity: ['1', '0.3'] as [string, string]
22
+ };
23
+
24
+ /**
25
+ * Validation schema for ripple configuration
26
+ */
27
+ export const RIPPLE_SCHEMA = {
28
+ duration: {
29
+ type: 'number',
30
+ minimum: 0,
31
+ default: RIPPLE_CONFIG.duration
32
+ },
33
+ timing: {
34
+ type: 'string',
35
+ enum: Object.values(RIPPLE_TIMING),
36
+ default: RIPPLE_CONFIG.timing
37
+ },
38
+ opacity: {
39
+ type: 'array',
40
+ items: {
41
+ type: 'string',
42
+ pattern: '^[0-1](\\.\\d+)?$'
43
+ },
44
+ minItems: 2,
45
+ maxItems: 2,
46
+ default: RIPPLE_CONFIG.opacity
47
+ }
48
+ };
@@ -0,0 +1,137 @@
1
+ // src/core/build/icon.ts
2
+ /**
3
+ * @module core/build
4
+ */
5
+
6
+ /**
7
+ * Options for creating an icon element
8
+ */
9
+ export interface IconElementOptions {
10
+ /**
11
+ * CSS class prefix
12
+ */
13
+ prefix?: string;
14
+
15
+ /**
16
+ * Additional CSS class
17
+ */
18
+ class?: string;
19
+
20
+ /**
21
+ * Icon size variant
22
+ */
23
+ size?: string;
24
+ }
25
+
26
+ /**
27
+ * Configuration for icon manager
28
+ */
29
+ export interface IconConfig {
30
+ /**
31
+ * CSS class prefix
32
+ */
33
+ prefix?: string;
34
+
35
+ /**
36
+ * Component type
37
+ */
38
+ type?: string;
39
+
40
+ /**
41
+ * Icon position ('start' or 'end')
42
+ */
43
+ position?: 'start' | 'end';
44
+
45
+ /**
46
+ * Icon size
47
+ */
48
+ iconSize?: string;
49
+ }
50
+
51
+ /**
52
+ * Icon manager interface
53
+ */
54
+ export interface IconManager {
55
+ /**
56
+ * Sets icon HTML content
57
+ * @param html - Icon HTML content
58
+ * @returns IconManager instance for chaining
59
+ */
60
+ setIcon: (html: string) => IconManager;
61
+
62
+ /**
63
+ * Gets current icon HTML content
64
+ * @returns Current icon HTML
65
+ */
66
+ getIcon: () => string;
67
+
68
+ /**
69
+ * Gets icon element
70
+ * @returns Icon element or null if not created
71
+ */
72
+ getElement: () => HTMLElement | null;
73
+ }
74
+
75
+ /**
76
+ * Creates an icon DOM element
77
+ *
78
+ * @param html - Icon HTML content
79
+ * @param options - Icon options
80
+ * @returns Icon element
81
+ * @private
82
+ */
83
+ const createIconElement = (html: string, options: IconElementOptions = {}): HTMLElement => {
84
+ const PREFIX = options.prefix || 'mtrl';
85
+ const element = document.createElement('span');
86
+ element.className = `${PREFIX}-icon`;
87
+
88
+ if (options.class) {
89
+ element.classList.add(options.class);
90
+ }
91
+ if (options.size) {
92
+ element.classList.add(`${PREFIX}-icon--${options.size}`);
93
+ }
94
+
95
+ element.innerHTML = html;
96
+ return element;
97
+ };
98
+
99
+ /**
100
+ * Creates an icon manager for a component
101
+ *
102
+ * @param element - Parent element
103
+ * @param config - Icon configuration
104
+ * @returns Icon manager interface
105
+ */
106
+ export const createIcon = (element: HTMLElement, config: IconConfig = {}): IconManager => {
107
+ let iconElement: HTMLElement | null = null;
108
+ const PREFIX = config.prefix || 'mtrl';
109
+
110
+ return {
111
+ setIcon(html: string): IconManager {
112
+ if (!iconElement && html) {
113
+ iconElement = createIconElement(html, {
114
+ prefix: PREFIX,
115
+ class: `${PREFIX}-${config.type || 'component'}-icon`,
116
+ size: config.iconSize
117
+ });
118
+ if (config.position === 'end') {
119
+ element.appendChild(iconElement);
120
+ } else {
121
+ element.insertBefore(iconElement, element.firstChild);
122
+ }
123
+ } else if (iconElement && html) {
124
+ iconElement.innerHTML = html;
125
+ }
126
+ return this;
127
+ },
128
+
129
+ getIcon(): string {
130
+ return iconElement ? iconElement.innerHTML : '';
131
+ },
132
+
133
+ getElement(): HTMLElement | null {
134
+ return iconElement;
135
+ }
136
+ };
137
+ };
@@ -0,0 +1,216 @@
1
+ // src/core/build/ripple.ts
2
+
3
+ import { RIPPLE_CONFIG, RIPPLE_TIMING } from './constants';
4
+
5
+ /**
6
+ * Ripple animation configuration
7
+ */
8
+ export interface RippleConfig {
9
+ /**
10
+ * Animation duration in milliseconds
11
+ */
12
+ duration?: number;
13
+
14
+ /**
15
+ * Animation timing function
16
+ */
17
+ timing?: string;
18
+
19
+ /**
20
+ * Opacity start and end values
21
+ */
22
+ opacity?: [string, string];
23
+ }
24
+
25
+ /**
26
+ * End coordinates for ripple animation
27
+ */
28
+ interface EndCoordinates {
29
+ size: string;
30
+ top: string;
31
+ left: string;
32
+ }
33
+
34
+ /**
35
+ * Document event listener
36
+ */
37
+ interface DocumentListener {
38
+ event: string;
39
+ handler: EventListener;
40
+ }
41
+
42
+ /**
43
+ * Ripple controller interface
44
+ */
45
+ export interface RippleController {
46
+ /**
47
+ * Attaches ripple effect to an element
48
+ * @param element - Target element
49
+ */
50
+ mount: (element: HTMLElement) => void;
51
+
52
+ /**
53
+ * Removes ripple effect from an element
54
+ * @param element - Target element
55
+ */
56
+ unmount: (element: HTMLElement) => void;
57
+ }
58
+
59
+ /**
60
+ * Creates a ripple effect instance
61
+ *
62
+ * @param config - Ripple configuration
63
+ * @returns Ripple controller instance
64
+ */
65
+ export const createRipple = (config: RippleConfig = {}): RippleController => {
66
+ // Make sure we fully merge the config options
67
+ const options = {
68
+ ...RIPPLE_CONFIG,
69
+ ...config,
70
+ // Handle nested objects like opacity array
71
+ opacity: config.opacity || RIPPLE_CONFIG.opacity
72
+ };
73
+
74
+ const getEndCoordinates = (bounds: DOMRect): EndCoordinates => {
75
+ const size = Math.max(bounds.width, bounds.height);
76
+ const top = bounds.height > bounds.width
77
+ ? -bounds.height / 2
78
+ : -(bounds.width - bounds.height / 2);
79
+
80
+ return {
81
+ size: `${size * 2}px`,
82
+ top: `${top}px`,
83
+ left: `${size / -2}px`
84
+ };
85
+ };
86
+
87
+ const createRippleElement = (): HTMLDivElement => {
88
+ const ripple = document.createElement('div');
89
+ ripple.className = 'ripple';
90
+ // Initial styles already set in CSS
91
+ ripple.style.transition = `all ${options.duration}ms ${options.timing}`;
92
+ return ripple;
93
+ };
94
+
95
+ // Store document event listeners for cleanup
96
+ let documentListeners: DocumentListener[] = [];
97
+
98
+ // Safe document event handling
99
+ const addDocumentListener = (event: string, handler: EventListener): void => {
100
+ if (typeof document.addEventListener === 'function') {
101
+ document.addEventListener(event, handler);
102
+ documentListeners.push({ event, handler });
103
+ }
104
+ };
105
+
106
+ const removeDocumentListener = (event: string, handler: EventListener): void => {
107
+ if (typeof document.removeEventListener === 'function') {
108
+ document.removeEventListener(event, handler);
109
+ documentListeners = documentListeners.filter(
110
+ listener => !(listener.event === event && listener.handler === handler)
111
+ );
112
+ }
113
+ };
114
+
115
+ const animate = (event: MouseEvent, container: HTMLElement): void => {
116
+ if (!container) return;
117
+
118
+ const bounds = container.getBoundingClientRect();
119
+ const ripple = createRippleElement();
120
+
121
+ // Set initial position and state
122
+ Object.assign(ripple.style, {
123
+ left: `${event.offsetX || bounds.width / 2}px`,
124
+ top: `${event.offsetY || bounds.height / 2}px`,
125
+ transform: 'scale(0)',
126
+ opacity: options.opacity[0]
127
+ });
128
+
129
+ container.appendChild(ripple);
130
+
131
+ // Force reflow
132
+ // eslint-disable-next-line no-unused-expressions
133
+ ripple.offsetHeight;
134
+
135
+ // Animate to end position
136
+ const end = getEndCoordinates(bounds);
137
+ Object.assign(ripple.style, {
138
+ ...end,
139
+ transform: 'scale(1)',
140
+ opacity: options.opacity[1]
141
+ });
142
+
143
+ const cleanup = () => {
144
+ ripple.style.opacity = '0';
145
+
146
+ // Use setTimeout to remove element after animation
147
+ setTimeout(() => {
148
+ if (ripple.parentNode) {
149
+ ripple.parentNode.removeChild(ripple);
150
+ }
151
+ }, options.duration);
152
+
153
+ removeDocumentListener('mouseup', cleanup);
154
+ removeDocumentListener('mouseleave', cleanup);
155
+ };
156
+
157
+ addDocumentListener('mouseup', cleanup);
158
+ addDocumentListener('mouseleave', cleanup);
159
+ };
160
+
161
+ return {
162
+ mount: (element: HTMLElement): void => {
163
+ if (!element) return;
164
+
165
+ // Ensure proper positioning context
166
+ const currentPosition = window.getComputedStyle(element).position;
167
+ if (currentPosition === 'static') {
168
+ element.style.position = 'relative';
169
+ }
170
+ element.style.overflow = 'hidden';
171
+
172
+ // Store the mousedown handler to be able to remove it later
173
+ const mousedownHandler = (e: MouseEvent) => animate(e, element);
174
+
175
+ // Store handler reference on the element
176
+ if (!element.__rippleHandlers) {
177
+ element.__rippleHandlers = [];
178
+ }
179
+ element.__rippleHandlers.push(mousedownHandler);
180
+
181
+ element.addEventListener('mousedown', mousedownHandler);
182
+ },
183
+
184
+ unmount: (element: HTMLElement): void => {
185
+ if (!element) return;
186
+
187
+ // Clear document event listeners
188
+ documentListeners.forEach(({ event, handler }) => {
189
+ removeDocumentListener(event, handler);
190
+ });
191
+ documentListeners = [];
192
+
193
+ // Remove event listeners
194
+ if (element.__rippleHandlers) {
195
+ element.__rippleHandlers.forEach(handler => {
196
+ element.removeEventListener('mousedown', handler);
197
+ });
198
+ element.__rippleHandlers = [];
199
+ }
200
+
201
+ // Remove all ripple elements
202
+ const ripples = element.querySelectorAll('.ripple');
203
+ ripples.forEach(ripple => {
204
+ // Call remove directly to match the test expectation
205
+ ripple.remove();
206
+ });
207
+ }
208
+ };
209
+ };
210
+
211
+ // Extend the HTMLElement interface to add rippleHandlers property
212
+ declare global {
213
+ interface HTMLElement {
214
+ __rippleHandlers?: Array<(e: MouseEvent) => void>;
215
+ }
216
+ }
@@ -0,0 +1,91 @@
1
+ // src/core/build/text.ts
2
+ /**
3
+ * @module core/build
4
+ */
5
+
6
+ /**
7
+ * Configuration for text manager
8
+ */
9
+ export interface TextConfig {
10
+ /**
11
+ * CSS class prefix
12
+ */
13
+ prefix?: string;
14
+
15
+ /**
16
+ * Component type
17
+ */
18
+ type?: string;
19
+
20
+ /**
21
+ * Element to insert before
22
+ */
23
+ beforeElement?: HTMLElement;
24
+ }
25
+
26
+ /**
27
+ * Text manager interface
28
+ */
29
+ export interface TextManager {
30
+ /**
31
+ * Sets text content
32
+ * @param text - Text content to set
33
+ * @returns TextManager instance for chaining
34
+ */
35
+ setText: (text: string) => TextManager;
36
+
37
+ /**
38
+ * Gets current text content
39
+ * @returns Current text
40
+ */
41
+ getText: () => string;
42
+
43
+ /**
44
+ * Gets text element
45
+ * @returns Text element or null if not created
46
+ */
47
+ getElement: () => HTMLElement | null;
48
+ }
49
+
50
+ /**
51
+ * Creates a text manager for a component
52
+ *
53
+ * @param element - Parent element
54
+ * @param config - Text configuration
55
+ * @returns Text manager interface
56
+ */
57
+ export const createText = (element: HTMLElement, config: TextConfig = {}): TextManager => {
58
+ let textElement: HTMLElement | null = null;
59
+ const PREFIX = config.prefix || 'mtrl';
60
+
61
+ const createElement = (content: string): HTMLElement => {
62
+ const span = document.createElement('span');
63
+ span.className = `${PREFIX}-${config.type || 'component'}-text`;
64
+ span.textContent = content;
65
+ return span;
66
+ };
67
+
68
+ return {
69
+ setText(text: string): TextManager {
70
+ if (!textElement && text) {
71
+ textElement = createElement(text);
72
+ if (config.beforeElement) {
73
+ element.insertBefore(textElement, config.beforeElement);
74
+ } else {
75
+ element.appendChild(textElement);
76
+ }
77
+ } else if (textElement) {
78
+ textElement.textContent = text;
79
+ }
80
+ return this;
81
+ },
82
+
83
+ getText(): string {
84
+ return textElement ? textElement.textContent || '' : '';
85
+ },
86
+
87
+ getElement(): HTMLElement | null {
88
+ return textElement;
89
+ }
90
+ };
91
+ };