@yewdiel21/motion-kit 0.17.0 → 0.19.0
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/README.md +240 -199
- package/dist/auto/index.d.ts +19 -3
- package/dist/auto/index.js +35 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-QFBOHPWZ.js → chunk-CQP66AZK.js} +2 -2
- package/dist/{chunk-QP4QJOEN.js → chunk-H4STLMCM.js} +2 -2
- package/dist/{chunk-BZLMCHUD.js → chunk-KDIJX6G4.js} +3 -3
- package/dist/{chunk-DOOMULVV.js → chunk-OLT3CWDJ.js} +4 -2
- package/dist/chunk-OLT3CWDJ.js.map +1 -0
- package/dist/{chunk-VJFPTLAK.js → chunk-PEDYJ2TV.js} +8 -22
- package/dist/chunk-PEDYJ2TV.js.map +1 -0
- package/dist/{chunk-7IQTMF7Y.js → chunk-Y4GIBTRG.js} +5130 -2153
- package/dist/chunk-Y4GIBTRG.js.map +1 -0
- package/dist/css/components/badge-button.css +16 -0
- package/dist/css/components/bento-grid.css +13 -0
- package/dist/css/components/code-block.css +267 -0
- package/dist/css/components/command-search.css +274 -2
- package/dist/css/components/dialog.css +264 -0
- package/dist/css/components/drilldown-menu.css +11 -7
- package/dist/css/components/equalizer-slider.css +26 -0
- package/dist/css/components/handwriting-text.css +15 -0
- package/dist/css/components/heatmap-chart.css +267 -1
- package/dist/css/components/magnetize-button.css +18 -0
- package/dist/css/components/morphing-page-dots.css +13 -0
- package/dist/css/components/not-found.css +22 -0
- package/dist/css/components/notification-stack.css +42 -0
- package/dist/css/components/particle-button.css +14 -0
- package/dist/css/components/radio-group.css +29 -0
- package/dist/css/components/rating-scale.css +23 -0
- package/dist/css/components/scroll-area.css +261 -0
- package/dist/css/components/scroll-preview-dock.css +18 -0
- package/dist/css/components/select.css +300 -4
- package/dist/css/components/signature-pad.css +130 -103
- package/dist/css/components/stepper.css +36 -0
- package/dist/css/components/sticky-header.css +10 -0
- package/dist/css/components/temperature-slider.css +29 -0
- package/dist/css/components/voice-message-bubble.css +62 -45
- package/dist/css/components/waveform-player.css +49 -34
- package/dist/css/components/wheel-picker.css +16 -15
- package/dist/css/tokens.css +4 -0
- package/dist/elements/index.d.ts +217 -4
- package/dist/elements/index.js +614 -9
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-Bn6hC87X.d.ts → engine-D6XV-WNt.d.ts} +390 -1
- package/dist/gl/index.js +2 -2
- package/dist/index.css +886 -177
- package/dist/index.d.ts +23 -23
- package/dist/index.js +37 -5
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +126 -7
- package/dist/react/index.js +722 -7
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-YS6TZjpa.d.ts → reduced-4croIXl-.d.ts} +1 -1
- package/dist/{store-CZ6vdtIE.d.ts → store-CAHWkurn.d.ts} +1 -1
- package/package.json +110 -110
- package/registry.json +1357 -1160
- package/src/auto/auto-init.ts +341 -308
- package/src/components/badge-button/README.md +28 -0
- package/src/components/badge-button/badge-button.css +15 -0
- package/src/components/badge-button/element.ts +16 -0
- package/src/components/badge-button/engine.ts +85 -0
- package/src/components/badge-button/react.tsx +31 -0
- package/src/components/bento-grid/README.md +26 -0
- package/src/components/bento-grid/bento-grid.css +12 -0
- package/src/components/bento-grid/element.ts +22 -0
- package/src/components/bento-grid/engine.ts +153 -0
- package/src/components/bento-grid/react.tsx +25 -0
- package/src/components/code-block/README.md +1 -1
- package/src/components/code-block/code-block.css +5 -0
- package/src/components/code-block/engine.ts +5 -0
- package/src/components/command-search/README.md +1 -1
- package/src/components/command-search/command-search.css +12 -2
- package/src/components/command-search/engine.ts +6 -0
- package/src/components/dialog/README.md +104 -103
- package/src/components/dialog/dialog.css +2 -0
- package/src/components/dialog/engine.ts +465 -449
- package/src/components/drilldown-menu/drilldown-menu.css +11 -7
- package/src/components/drilldown-menu/engine.ts +44 -4
- package/src/components/equalizer-slider/README.md +43 -0
- package/src/components/equalizer-slider/element.ts +41 -0
- package/src/components/equalizer-slider/engine.ts +32 -0
- package/src/components/equalizer-slider/equalizer-slider.css +26 -0
- package/src/components/equalizer-slider/react.tsx +60 -0
- package/src/components/equalizer-slider/shared.ts +161 -0
- package/src/components/handwriting-text/README.md +29 -0
- package/src/components/handwriting-text/element.ts +14 -0
- package/src/components/handwriting-text/engine.ts +108 -0
- package/src/components/handwriting-text/glyphs.ts +71 -0
- package/src/components/handwriting-text/handwriting-text.css +14 -0
- package/src/components/handwriting-text/react.tsx +23 -0
- package/src/components/heatmap-chart/README.md +48 -48
- package/src/components/heatmap-chart/engine.ts +6 -1
- package/src/components/heatmap-chart/heatmap-chart.css +5 -1
- package/src/components/icons.ts +1 -0
- package/src/components/magnetize-button/README.md +26 -0
- package/src/components/magnetize-button/element.ts +13 -0
- package/src/components/magnetize-button/engine.ts +84 -0
- package/src/components/magnetize-button/magnetize-button.css +17 -0
- package/src/components/magnetize-button/react.tsx +22 -0
- package/src/components/morphing-page-dots/README.md +28 -0
- package/src/components/morphing-page-dots/element.ts +16 -0
- package/src/components/morphing-page-dots/engine.ts +89 -0
- package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
- package/src/components/morphing-page-dots/react.tsx +32 -0
- package/src/components/not-found/README.md +26 -0
- package/src/components/not-found/element.ts +15 -0
- package/src/components/not-found/engine.ts +57 -0
- package/src/components/not-found/not-found.css +21 -0
- package/src/components/not-found/react.tsx +23 -0
- package/src/components/notification-stack/README.md +24 -0
- package/src/components/notification-stack/element.ts +26 -0
- package/src/components/notification-stack/engine.ts +329 -0
- package/src/components/notification-stack/notification-stack.css +41 -0
- package/src/components/notification-stack/react.tsx +28 -0
- package/src/components/particle-button/README.md +26 -0
- package/src/components/particle-button/element.ts +13 -0
- package/src/components/particle-button/engine.ts +52 -0
- package/src/components/particle-button/particle-button.css +13 -0
- package/src/components/particle-button/react.tsx +22 -0
- package/src/components/radio-group/README.md +43 -0
- package/src/components/radio-group/element.ts +41 -0
- package/src/components/radio-group/engine.ts +11 -0
- package/src/components/radio-group/radio-group.css +28 -0
- package/src/components/radio-group/react.tsx +60 -0
- package/src/components/radio-group/shared.ts +118 -0
- package/src/components/rating-scale/README.md +43 -0
- package/src/components/rating-scale/element.ts +41 -0
- package/src/components/rating-scale/engine.ts +63 -0
- package/src/components/rating-scale/rating-scale.css +22 -0
- package/src/components/rating-scale/react.tsx +60 -0
- package/src/components/scroll-area/README.md +89 -0
- package/src/components/scroll-area/element.ts +35 -0
- package/src/components/scroll-area/engine.ts +528 -0
- package/src/components/scroll-area/react.tsx +67 -0
- package/src/components/scroll-area/scroll-area.css +260 -0
- package/src/components/scroll-preview-dock/README.md +24 -0
- package/src/components/scroll-preview-dock/element.ts +20 -0
- package/src/components/scroll-preview-dock/engine.ts +228 -0
- package/src/components/scroll-preview-dock/react.tsx +26 -0
- package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
- package/src/components/select/README.md +23 -9
- package/src/components/select/engine.ts +16 -4
- package/src/components/select/select.css +38 -4
- package/src/components/signature-pad/engine.ts +550 -437
- package/src/components/signature-pad/signature-pad.css +130 -103
- package/src/components/stepper/README.md +46 -0
- package/src/components/stepper/element.ts +42 -0
- package/src/components/stepper/engine.ts +103 -0
- package/src/components/stepper/react.tsx +60 -0
- package/src/components/stepper/stepper.css +35 -0
- package/src/components/sticky-header/README.md +30 -0
- package/src/components/sticky-header/element.ts +19 -0
- package/src/components/sticky-header/engine.ts +95 -0
- package/src/components/sticky-header/react.tsx +25 -0
- package/src/components/sticky-header/sticky-header.css +9 -0
- package/src/components/temperature-slider/README.md +42 -0
- package/src/components/temperature-slider/element.ts +53 -0
- package/src/components/temperature-slider/engine.ts +44 -0
- package/src/components/temperature-slider/react.tsx +66 -0
- package/src/components/temperature-slider/temperature-slider.css +29 -0
- package/src/components/voice-message-bubble/engine.ts +11 -0
- package/src/components/voice-message-bubble/voice-message-bubble.css +14 -12
- package/src/components/waveform-player/README.md +1 -1
- package/src/components/waveform-player/engine.ts +344 -261
- package/src/components/waveform-player/waveform-player.css +50 -35
- package/src/components/wheel-picker/README.md +3 -1
- package/src/components/wheel-picker/element.ts +12 -5
- package/src/components/wheel-picker/engine.ts +37 -8
- package/src/components/wheel-picker/wheel-picker.css +17 -16
- package/src/core/timing.ts +2 -0
- package/src/css/tokens.css +4 -0
- package/src/elements/index.ts +321 -273
- package/src/index.ts +312 -295
- package/src/motion/morph.ts +5 -2
- package/src/react/index.ts +17 -0
- package/dist/chunk-7IQTMF7Y.js.map +0 -1
- package/dist/chunk-DOOMULVV.js.map +0 -1
- package/dist/chunk-VJFPTLAK.js.map +0 -1
- /package/dist/{chunk-QFBOHPWZ.js.map → chunk-CQP66AZK.js.map} +0 -0
- /package/dist/{chunk-QP4QJOEN.js.map → chunk-H4STLMCM.js.map} +0 -0
- /package/dist/{chunk-BZLMCHUD.js.map → chunk-KDIJX6G4.js.map} +0 -0
|
@@ -8,13 +8,13 @@ mk-drilldown-menu {
|
|
|
8
8
|
width: 100%;
|
|
9
9
|
max-width: 26rem;
|
|
10
10
|
min-width: 0;
|
|
11
|
-
padding:
|
|
12
|
-
border:
|
|
11
|
+
padding: 12px;
|
|
12
|
+
border: 0;
|
|
13
13
|
border-radius: var(--mk-drilldown-radius);
|
|
14
14
|
background: var(--mk-surface-1);
|
|
15
15
|
color: var(--mk-text);
|
|
16
16
|
font-family: var(--md-sys-typescale-body-large-font, inherit);
|
|
17
|
-
|
|
17
|
+
font-family: var(--mk-font);
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
[data-mk-drilldown-header] {
|
|
@@ -85,8 +85,9 @@ mk-drilldown-menu {
|
|
|
85
85
|
cursor: pointer;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
[data-mk-drilldown-viewport] { overflow: hidden; border-radius: var(--mk-drilldown-item-radius); }
|
|
89
|
-
[data-mk-drilldown-
|
|
88
|
+
[data-mk-drilldown-viewport] { position: relative; isolation: isolate; overflow: hidden; border-radius: var(--mk-drilldown-item-radius); }
|
|
89
|
+
[data-mk-drilldown-highlight] { position: absolute; top: 0; left: 0; z-index: -1; border-radius: var(--mk-radius-lg); background: var(--mk-surface-3); pointer-events: none; opacity: 0; }
|
|
90
|
+
[data-mk-drilldown-level] { position: relative; display: grid; gap: 6px; }
|
|
90
91
|
[data-mk-drilldown-item] {
|
|
91
92
|
display: flex;
|
|
92
93
|
align-items: center;
|
|
@@ -104,16 +105,19 @@ mk-drilldown-menu {
|
|
|
104
105
|
line-height: 1.4;
|
|
105
106
|
text-align: start;
|
|
106
107
|
cursor: pointer;
|
|
107
|
-
transition:
|
|
108
|
+
transition: color .15s, border-radius .3s var(--mk-ease-playful);
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
[data-mk-drilldown-item]:enabled:hover,
|
|
111
|
-
[data-mk-drilldown-item]:focus-visible { background: var(--mk-
|
|
112
|
+
[data-mk-drilldown-item]:focus-visible { background: color-mix(in srgb, var(--mk-text) 3%, transparent); border-radius: var(--mk-radius-xl); }
|
|
113
|
+
[data-mk-drilldown-item][data-mk-chosen]::after { content: '✓'; display: grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; color: var(--mk-on-accent); background: var(--mk-accent); }
|
|
112
114
|
[data-mk-drilldown-item]:disabled { opacity: 0.42; cursor: default; }
|
|
113
115
|
[data-mk-drilldown-text] { display: grid; gap: 3px; min-width: 0; overflow-wrap: anywhere; }
|
|
114
116
|
[data-mk-drilldown-label] { font-weight: 550; }
|
|
115
117
|
[data-mk-drilldown-description] { color: var(--mk-muted); font-size: 0.8rem; }
|
|
116
118
|
[data-mk-drilldown-arrow] { display: inline-flex; flex: none; color: var(--mk-muted); }
|
|
119
|
+
[data-mk-drilldown-arrow] svg { transition: transform .35s var(--mk-ease-swatch); }
|
|
120
|
+
[data-mk-highlighted] [data-mk-drilldown-arrow] svg { transform: translateX(3px); }
|
|
117
121
|
[data-mk-drilldown-empty] { margin: 0; padding: 24px 16px; color: var(--mk-muted); font-size: 0.875rem; }
|
|
118
122
|
|
|
119
123
|
[data-mk-drilldown-item]:focus-visible,
|
|
@@ -2,6 +2,7 @@ import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
|
2
2
|
import { reduced } from '../../core/reduced';
|
|
3
3
|
import { createScope } from '../../core/scope';
|
|
4
4
|
import { inert, type Instance } from '../../core/types';
|
|
5
|
+
import { press, ringPulse } from '../../motion/micro';
|
|
5
6
|
|
|
6
7
|
export interface DrilldownMenuItem {
|
|
7
8
|
/** Unique among siblings; IDs form the navigation path. */
|
|
@@ -66,6 +67,12 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
66
67
|
let focused: string | null = null;
|
|
67
68
|
const remembered = new Map<string, string>();
|
|
68
69
|
let animation: gsap.core.Timeline | null = null;
|
|
70
|
+
let hoverMotion: gsap.core.Tween | null = null;
|
|
71
|
+
const effects = new Set<gsap.core.Animation>();
|
|
72
|
+
const keep = (effect: gsap.core.Animation | null) => {
|
|
73
|
+
for (const animation of effects) if (animation.totalProgress() === 1) effects.delete(animation);
|
|
74
|
+
if (effect) effects.add(effect);
|
|
75
|
+
};
|
|
69
76
|
|
|
70
77
|
const header = document.createElement('div');
|
|
71
78
|
header.dataset.mkDrilldownHeader = '';
|
|
@@ -91,7 +98,9 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
91
98
|
const empty = document.createElement('p');
|
|
92
99
|
empty.dataset.mkDrilldownEmpty = '';
|
|
93
100
|
empty.setAttribute('role', 'status');
|
|
94
|
-
|
|
101
|
+
const highlight = document.createElement('span');
|
|
102
|
+
highlight.dataset.mkDrilldownHighlight = ''; highlight.setAttribute('aria-hidden', 'true');
|
|
103
|
+
viewport.append(highlight, menu, empty);
|
|
95
104
|
backButton.setAttribute('aria-controls', menu.id);
|
|
96
105
|
root.append(header, breadcrumb, viewport);
|
|
97
106
|
scope.attr(root, 'data-mk-ready', '');
|
|
@@ -112,10 +121,19 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
112
121
|
const level = () => levelAt(path);
|
|
113
122
|
const enabled = () => [...menu.querySelectorAll<HTMLButtonElement>('[data-mk-drilldown-item]')].filter((item) => !item.disabled);
|
|
114
123
|
const buttonOf = (id: string) => enabled().find((item) => item.dataset.mkDrilldownItem === id);
|
|
124
|
+
const highlightItem = (button?: HTMLButtonElement | null) => {
|
|
125
|
+
hoverMotion?.kill();
|
|
126
|
+
for (const item of enabled()) item.toggleAttribute('data-mk-highlighted', item === button);
|
|
127
|
+
if (!button) { highlight.style.opacity = '0'; return; }
|
|
128
|
+
const geometry = { x: button.offsetLeft + menu.offsetLeft, y: button.offsetTop + menu.offsetTop, width: button.offsetWidth, height: button.offsetHeight, opacity: 1 };
|
|
129
|
+
if (reduced()) gsap.set(highlight, geometry);
|
|
130
|
+
else hoverMotion = gsap.to(highlight, { ...geometry, duration: .35, ease: EASE.swatch });
|
|
131
|
+
};
|
|
115
132
|
const cancelMotion = () => {
|
|
116
133
|
animation?.kill();
|
|
134
|
+
hoverMotion?.kill();
|
|
117
135
|
animation = null;
|
|
118
|
-
gsap.set(menu, { clearProps: 'transform,opacity,filter' });
|
|
136
|
+
gsap.set([menu, heading, breadcrumb, ...menu.children], { clearProps: 'transform,opacity,filter' });
|
|
119
137
|
gsap.set(viewport, { clearProps: 'height' });
|
|
120
138
|
};
|
|
121
139
|
const roving = (id: string | null, focus = false) => {
|
|
@@ -127,6 +145,7 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
127
145
|
menu.tabIndex = target ? -1 : 0;
|
|
128
146
|
if (focused) remembered.set(JSON.stringify(path), focused);
|
|
129
147
|
if (focus) (target ?? menu).focus({ preventScroll: true });
|
|
148
|
+
if (focus) highlightItem(target);
|
|
130
149
|
};
|
|
131
150
|
const notifyPath = () => {
|
|
132
151
|
const next = [...path];
|
|
@@ -137,6 +156,7 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
137
156
|
const render = (direction: number, focus: boolean, preferred?: string | null) => {
|
|
138
157
|
const oldHeight = viewport.getBoundingClientRect().height;
|
|
139
158
|
cancelMotion();
|
|
159
|
+
highlightItem(null);
|
|
140
160
|
const current = level();
|
|
141
161
|
const label = config.label ?? 'Actions';
|
|
142
162
|
const title = current.ancestors.at(-1)?.label ?? label;
|
|
@@ -144,6 +164,8 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
144
164
|
backButton.hidden = path.length === 0;
|
|
145
165
|
backButton.setAttribute('aria-label', config.backLabel ?? 'Back');
|
|
146
166
|
breadcrumb.setAttribute('aria-label', `${label} path`);
|
|
167
|
+
// At the root the heading already names the only crumb.
|
|
168
|
+
breadcrumb.hidden = path.length === 0;
|
|
147
169
|
const labels = [label, ...current.ancestors.map((item) => item.label)];
|
|
148
170
|
crumbs.replaceChildren(...labels.map((text, index) => {
|
|
149
171
|
const li = document.createElement('li');
|
|
@@ -203,6 +225,8 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
203
225
|
// duplicated menuitems and allows rapid navigation without stale callbacks.
|
|
204
226
|
animation = gsap.timeline({ onComplete: () => { animation = null; } })
|
|
205
227
|
.fromTo(menu, { x: 28 * direction, opacity: 0, filter: 'blur(6px)' }, { x: 0, opacity: 1, filter: 'blur(0px)', duration: 0.36, ease: EASE.signature, clearProps: 'transform,opacity,filter' });
|
|
228
|
+
animation.fromTo([heading, breadcrumb], { y: -6, opacity: .3, filter: 'blur(6px)' }, { y: 0, opacity: 1, filter: 'blur(0px)', duration: .3, ease: EASE.pop, clearProps: 'transform,opacity,filter' }, 0)
|
|
229
|
+
.fromTo([...menu.children], { x: 12 * direction, opacity: 0 }, { x: 0, opacity: 1, duration: .3, stagger: .035, ease: EASE.swatchLight, clearProps: 'transform,opacity' }, .04);
|
|
206
230
|
const nextHeight = viewport.getBoundingClientRect().height;
|
|
207
231
|
if (oldHeight > 0 && nextHeight > 0 && oldHeight !== nextHeight) {
|
|
208
232
|
animation.fromTo(viewport, { height: oldHeight }, { height: nextHeight, duration: 0.36, ease: EASE.signature, clearProps: 'height' }, 0);
|
|
@@ -237,6 +261,11 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
237
261
|
const item = level().items.find((entry) => entry.id === id);
|
|
238
262
|
if (!item || item.disabled) return;
|
|
239
263
|
if (branch(item)) return void open(id);
|
|
264
|
+
const button = buttonOf(id);
|
|
265
|
+
if (button) {
|
|
266
|
+
for (const row of enabled()) row.toggleAttribute('data-mk-chosen', row === button);
|
|
267
|
+
keep(press(button)); keep(ringPulse(button));
|
|
268
|
+
}
|
|
240
269
|
const selected = copyItems([item])[0]!;
|
|
241
270
|
const selectedPath = [...path, item.id];
|
|
242
271
|
root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { item: selected, path: [...selectedPath] } }));
|
|
@@ -251,8 +280,17 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
251
280
|
});
|
|
252
281
|
scope.on(menu, 'focusin', (event) => {
|
|
253
282
|
const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-item]');
|
|
254
|
-
if (button && !button.disabled) roving(button.dataset.mkDrilldownItem!, false);
|
|
283
|
+
if (button && !button.disabled) { roving(button.dataset.mkDrilldownItem!, false); highlightItem(button); }
|
|
284
|
+
});
|
|
285
|
+
scope.on(menu, 'pointerover', event => {
|
|
286
|
+
const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-item]');
|
|
287
|
+
if (button && !button.disabled) highlightItem(button);
|
|
255
288
|
});
|
|
289
|
+
scope.on(menu, 'pointerleave', () => highlightItem(menu.contains(document.activeElement) ? buttonOf(focused ?? '') : null));
|
|
290
|
+
if (typeof ResizeObserver === 'function') scope.observe(new ResizeObserver(() => {
|
|
291
|
+
const current = menu.querySelector<HTMLButtonElement>('[data-mk-highlighted]');
|
|
292
|
+
if (current) highlightItem(current);
|
|
293
|
+
})).observe(menu);
|
|
256
294
|
scope.on(root, 'keydown', (event) => {
|
|
257
295
|
const key = (event as KeyboardEvent).key;
|
|
258
296
|
if ((key === 'ArrowLeft' || key === 'Escape') && path.length) {
|
|
@@ -279,7 +317,7 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
279
317
|
activate(button.dataset.mkDrilldownItem!);
|
|
280
318
|
}
|
|
281
319
|
});
|
|
282
|
-
scope.on(backButton, 'click', () => back());
|
|
320
|
+
scope.on(backButton, 'click', () => { keep(press(backButton)); back(); });
|
|
283
321
|
scope.on(breadcrumb, 'click', (event) => {
|
|
284
322
|
const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-crumb]');
|
|
285
323
|
if (!button || !breadcrumb.contains(button)) return;
|
|
@@ -288,6 +326,8 @@ export function createDrilldownMenu(root: HTMLElement | null, options: Drilldown
|
|
|
288
326
|
});
|
|
289
327
|
scope.add(() => {
|
|
290
328
|
cancelMotion();
|
|
329
|
+
for (const effect of effects) effect.kill();
|
|
330
|
+
gsap.killTweensOf([heading, breadcrumb, highlight]);
|
|
291
331
|
remembered.clear();
|
|
292
332
|
header.remove();
|
|
293
333
|
breadcrumb.remove();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# EqualizerSlider
|
|
2
|
+
|
|
3
|
+
Un control de nivel con barras de onda y un cursor que acompaña inmediatamente al rango nativo. Las barras que cambian de estado responden con swatch; el número rueda con las teclas o la API y el cursor responde al presionarlo.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createEqualizerSlider } from '@yewdiel21/motion-kit';
|
|
7
|
+
import '@yewdiel21/motion-kit/css/equalizer-slider';
|
|
8
|
+
|
|
9
|
+
const control = createEqualizerSlider(document.querySelector('.mk-equalizer-slider'), {
|
|
10
|
+
value: 60, name: 'volume', label: 'Volumen',
|
|
11
|
+
onInput: (value) => { audio.volume = value / 100; },
|
|
12
|
+
});
|
|
13
|
+
control.setValue(35);
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Opciones y contrato
|
|
17
|
+
|
|
18
|
+
`min` (0), `max` (100), `step` (1), `value` / `defaultValue` (60), `waveform: number[]` (alturas 0–1), `bars` (40, 12–72), `label`, `name`, `disabled`, `error`, `formatValue`, `onInput(value)`, `onChange(value)`.
|
|
19
|
+
|
|
20
|
+
Es un control numérico; no reproduce, captura ni analiza audio. waveform es una forma decorativa estática. onInput se emite durante el ajuste; onChange al confirmar, una sola vez por valor. En React se puede actualizar con onInput o confirmar con onChange. El arrastre siempre cambia la geometría sin esperar a una animación.
|
|
21
|
+
|
|
22
|
+
La instancia expone `value: number`, `setValue(value, notify = false)`, `reset(notify = false)` y `destroy()`. Las escrituras imperativas actualizan el control incluso estando disabled. `setValue` no cambia el valor inicial al que vuelve reset. Las notificaciones usan callbacks y eventos `mk:input / mk:change` con `detail: { value }`.
|
|
23
|
+
|
|
24
|
+
## React y HTML
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { EqualizerSlider } from '@yewdiel21/motion-kit/react';
|
|
28
|
+
|
|
29
|
+
<EqualizerSlider value={level} onInput={setLevel} label="Nivel" name="level" />
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`value` controla la instancia; `defaultValue` inicia una instancia que conserva sus propios cambios. Las opciones pueden cambiar sin perder la selección no controlada. `instanceRef` recibe la instancia y admite cambiar de objeto ref. Los children y dangerouslySetInnerHTML no se pasan al host porque el motor posee ese DOM.
|
|
33
|
+
|
|
34
|
+
```html
|
|
35
|
+
<mk-equalizer-slider class="mk-equalizer-slider" value="60" label="Nivel" name="level" bars="44"></mk-equalizer-slider>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Registra con `defineEqualizerSlider()` desde `@yewdiel21/motion-kit/elements` o utiliza el auto registro. Los atributos de opciones se pueden cambiar en vivo; el valor imperativo se conserva al cambiar otra opción. `disabled="false"` se lee como false. El elemento expone `value`, `setValue`, `reset`.
|
|
39
|
+
|
|
40
|
+
## Accesibilidad y lifecycle
|
|
41
|
+
|
|
42
|
+
Se conserva el input nativo, su etiqueta, teclado, tacto, validación y participación en formularios. El reset de formulario es cancelable; el control espera al reset nativo y verifica defaultPrevented. Movimiento reducido conserva toda la funcionalidad. destroy es idempotente y libera animaciones, eventos y timers, elimina solo las partes creadas y restaura los atributos del host. Los temas utilizan DM Sans y tokens --mk-*; el CSS de este componente es independiente de los otros estilos.
|
|
43
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createEqualizerSlider, type EqualizerSliderInstance } from './engine';
|
|
3
|
+
|
|
4
|
+
/** Light DOM; attribute changes keep the current imperative value and reset baseline. */
|
|
5
|
+
export class MkEqualizerSliderElement extends MkElement<EqualizerSliderInstance> {
|
|
6
|
+
static options: Record<string, OptionType> = {"min":"number","max":"number","step":"number","value":"number","label":"string","name":"string","disabled":"boolean","error":"string","waveform":"json","bars":"number"};
|
|
7
|
+
private baseline: number | undefined;
|
|
8
|
+
private saved: number | undefined;
|
|
9
|
+
protected create(options: Record<string, unknown>) {
|
|
10
|
+
const created = createEqualizerSlider(this, { ...options, value: this.saved !== undefined ? this.saved : options.value as number | undefined, defaultValue: this.baseline });
|
|
11
|
+
if (this.baseline === undefined) this.baseline = created.value;
|
|
12
|
+
this.saved = undefined;
|
|
13
|
+
return created;
|
|
14
|
+
}
|
|
15
|
+
disconnectedCallback() {
|
|
16
|
+
this.saved = this.instance?.value;
|
|
17
|
+
super.disconnectedCallback();
|
|
18
|
+
}
|
|
19
|
+
attributeChangedCallback(name: string, previous: string | null, next: string | null) {
|
|
20
|
+
if (previous === next) return;
|
|
21
|
+
if (name === 'value' && (!this.instance || !this.isConnected)) {
|
|
22
|
+
this.saved = next === null ? this.baseline ?? 60 : Number.isFinite(Number(next)) ? Number(next) : this.baseline ?? 60;
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (!this.instance || !this.isConnected) return;
|
|
26
|
+
if (name === 'value') {
|
|
27
|
+
const parsed = next === null ? this.baseline ?? 60 : Number(next);
|
|
28
|
+
this.instance.setValue(parsed, false);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
this.saved = this.instance.value;
|
|
32
|
+
super.attributeChangedCallback(name, previous, next);
|
|
33
|
+
}
|
|
34
|
+
get value(): number { return this.instance?.value ?? this.saved ?? 60; }
|
|
35
|
+
set value(next: number) { this.setValue(next); }
|
|
36
|
+
setValue(value: number, notify = false) { if (this.instance) this.instance.setValue(value, notify); else this.saved = value; }
|
|
37
|
+
reset(notify = false) { this.instance?.reset(notify); }
|
|
38
|
+
|
|
39
|
+
}
|
|
40
|
+
export const defineEqualizerSlider = () => defineElement('mk-equalizer-slider', MkEqualizerSliderElement);
|
|
41
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { gsap, EASE } from '../../core/gsap';
|
|
2
|
+
import { reduced } from '../../core/reduced';
|
|
3
|
+
import { createNumericRange, type NumericOptions, type NumericInstance } from './shared';
|
|
4
|
+
|
|
5
|
+
export interface EqualizerSliderOptions extends NumericOptions {
|
|
6
|
+
/** Static waveform heights 0…1. This control does not analyze or play audio. */
|
|
7
|
+
waveform?: number[];
|
|
8
|
+
/** Number of decorative bars, 12…72; default 40. */
|
|
9
|
+
bars?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface EqualizerSliderInstance extends NumericInstance {}
|
|
12
|
+
export function createEqualizerSlider(root: HTMLElement | null, options: EqualizerSliderOptions = {}): EqualizerSliderInstance {
|
|
13
|
+
return createNumericRange(root, options, {
|
|
14
|
+
prefix: 'equalizer', label: 'Volumen', min: 0, max: 100, value: 60, format: (n) => `${n} %`,
|
|
15
|
+
decorate({ track, keep }) {
|
|
16
|
+
const wave = document.createElement('span'); wave.dataset.mkEqualizerWave = ''; wave.setAttribute('aria-hidden', 'true'); track.prepend(wave);
|
|
17
|
+
const count = Math.max(12, Math.min(72, Math.round(Number.isFinite(options.bars) ? options.bars! : 40)));
|
|
18
|
+
const bars = Array.from({ length: count }, (_, i) => {
|
|
19
|
+
const bar = document.createElement('span');
|
|
20
|
+
const supplied = options.waveform?.[i % options.waveform.length];
|
|
21
|
+
const height = Number.isFinite(supplied) ? Math.max(0.1, Math.min(1, supplied!)) : 0.25 + 0.75 * Math.abs(Math.sin(i * 1.73) * Math.cos(i * 0.41));
|
|
22
|
+
bar.style.height = `${height * 100}%`; wave.append(bar); return bar;
|
|
23
|
+
});
|
|
24
|
+
return (ratio, _value, animate) => bars.forEach((bar, i) => {
|
|
25
|
+
const on = i / Math.max(1, count - 1) <= ratio;
|
|
26
|
+
const changed = bar.hasAttribute('data-mk-on') !== on;
|
|
27
|
+
bar.toggleAttribute('data-mk-on', on);
|
|
28
|
+
if (animate && changed && !reduced()) keep(gsap.fromTo(bar, { scaleY: 0.72 }, { scaleY: 1, duration: 0.35, ease: EASE.swatch, overwrite: 'auto', clearProps: 'transform' }));
|
|
29
|
+
});
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
:is(.mk-equalizer-slider, mk-equalizer-slider) { display: block; width: 100%; min-width: 0; color: var(--mk-text); font: 500 .875rem/1.4 var(--mk-font); }
|
|
2
|
+
[data-mk-equalizer-body] { display: grid; gap: .875rem; }
|
|
3
|
+
[data-mk-equalizer-header] { display: flex; justify-content: space-between; align-items: center; gap: 1rem; }
|
|
4
|
+
[data-mk-equalizer-output] { font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
5
|
+
[data-mk-equalizer-track] { position: relative; height: 76px; min-width: 0; border-radius: 24px; background: var(--mk-surface-2); }
|
|
6
|
+
[data-mk-equalizer-input] { position: absolute; z-index: 3; inset: 0; box-sizing: border-box; width: 100%; height: 100%; margin: 0; appearance: none; -webkit-appearance: none; background: transparent; cursor: ew-resize; opacity: 0; }
|
|
7
|
+
[data-mk-equalizer-input]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; width: 32px; height: 76px; background: transparent; }
|
|
8
|
+
[data-mk-equalizer-input]::-moz-range-thumb { width: 32px; height: 76px; border: 0; background: transparent; }
|
|
9
|
+
[data-mk-equalizer-thumb] { position: absolute; z-index: 2; top: 50%; left: calc(16px + (100% - 32px) * var(--mk-equalizer-ratio, .5)); translate: -50% -50%; pointer-events: none; }
|
|
10
|
+
[data-mk-equalizer-track]:has(:focus-visible) { outline: 2px solid var(--mk-accent); outline-offset: 4px; }
|
|
11
|
+
[data-mk-equalizer-ends] { display: flex; justify-content: space-between; margin-top: -.375rem; color: var(--mk-muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
|
|
12
|
+
[data-mk-equalizer-message] { color: var(--mk-error); font-size: .8125rem; font-weight: 400; }
|
|
13
|
+
[data-mk-equalizer-message]:empty { display: none; }
|
|
14
|
+
:is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-disabled] { opacity: .4; }
|
|
15
|
+
:is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-disabled] [data-mk-equalizer-input] { cursor: not-allowed; }
|
|
16
|
+
:is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-error] [data-mk-equalizer-track] { outline: 1px solid var(--mk-error); }
|
|
17
|
+
[data-mk-equalizer-output] { padding: .375rem .75rem; border-radius: 64px; background: var(--mk-surface-2); font-size: .8125rem; }
|
|
18
|
+
[data-mk-equalizer-wave] { position: absolute; inset: 14px 16px; display: flex; align-items: center; gap: 3px; pointer-events: none; }
|
|
19
|
+
[data-mk-equalizer-wave] > span { flex: 1; min-width: 1px; border-radius: 64px; background: var(--mk-outline-soft); transform-origin: center; transition: background .16s; }
|
|
20
|
+
[data-mk-equalizer-wave] > span[data-mk-on] { background: var(--mk-text); }
|
|
21
|
+
[data-mk-equalizer-thumb] { width: 4px; height: 54px; border: 3px solid var(--mk-surface-2); border-radius: 64px; background: var(--mk-text); box-sizing: content-box; transition: height .35s var(--mk-ease-swatch), border-width .35s var(--mk-ease-swatch); }
|
|
22
|
+
:is(.mk-equalizer-slider, mk-equalizer-slider)[data-mk-held] [data-mk-equalizer-thumb] { height: 66px; border-width: 5px; }
|
|
23
|
+
@media (hover: hover) { [data-mk-equalizer-track]:has(:not(:disabled):hover) [data-mk-equalizer-thumb] { height: 62px; } }
|
|
24
|
+
@media (prefers-reduced-motion: reduce) { [data-mk-equalizer-thumb], [data-mk-equalizer-wave] > span { transition: none; } }
|
|
25
|
+
@media (forced-colors: active) { [data-mk-equalizer-wave] > span { background: GrayText; } [data-mk-equalizer-wave] > span[data-mk-on], [data-mk-equalizer-thumb] { background: Highlight; } }
|
|
26
|
+
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createEqualizerSlider, type EqualizerSliderOptions, type EqualizerSliderInstance } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface EqualizerSliderProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'onInput' | 'defaultValue' | 'children' | 'dangerouslySetInnerHTML'>, Omit<EqualizerSliderOptions, 'value' | 'defaultValue'> {
|
|
7
|
+
value?: number;
|
|
8
|
+
defaultValue?: number;
|
|
9
|
+
instanceRef?: MutableRefObject<EqualizerSliderInstance | null>;
|
|
10
|
+
/** The engine owns this component's DOM. */
|
|
11
|
+
children?: never;
|
|
12
|
+
dangerouslySetInnerHTML?: never;
|
|
13
|
+
}
|
|
14
|
+
export function EqualizerSlider({ value, defaultValue, min, max, step, label, name, disabled, error, formatValue, waveform, bars, onChange, onInput, instanceRef, className, children: _children, dangerouslySetInnerHTML: _html, ...props }: EqualizerSliderProps) {
|
|
15
|
+
const root = useRef<HTMLDivElement>(null);
|
|
16
|
+
const engine = useRef<EqualizerSliderInstance | null>(null);
|
|
17
|
+
const latestValue = useLatest(value);
|
|
18
|
+
const change = useLatest(onChange);
|
|
19
|
+
const input = useLatest(onInput);
|
|
20
|
+
const baseline = useRef(value !== undefined ? value : defaultValue);
|
|
21
|
+
const current = useRef(baseline.current);
|
|
22
|
+
const reconcile = () => queueMicrotask(() => {
|
|
23
|
+
if (latestValue.current !== undefined) engine.current?.setValue(latestValue.current, false);
|
|
24
|
+
});
|
|
25
|
+
useIsomorphicLayoutEffect(() => {
|
|
26
|
+
const created = createEqualizerSlider(root.current, {
|
|
27
|
+
min, max, step, label, name, disabled, error, formatValue, waveform, bars, value: latestValue.current !== undefined ? latestValue.current : current.current, defaultValue: baseline.current,
|
|
28
|
+
onChange: (next) => { change.current?.(next); reconcile(); },
|
|
29
|
+
onInput: (next) => { input.current?.(next); if (input.current || !change.current) reconcile(); },
|
|
30
|
+
});
|
|
31
|
+
engine.current = created;
|
|
32
|
+
const form = root.current?.closest('form');
|
|
33
|
+
const pending = new Set<ReturnType<typeof setTimeout>>();
|
|
34
|
+
const reset = (event: Event) => {
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
pending.delete(timer);
|
|
37
|
+
if (!event.defaultPrevented && latestValue.current !== undefined) created.setValue(latestValue.current, false);
|
|
38
|
+
}, 0);
|
|
39
|
+
pending.add(timer);
|
|
40
|
+
};
|
|
41
|
+
form?.addEventListener('reset', reset);
|
|
42
|
+
return () => {
|
|
43
|
+
form?.removeEventListener('reset', reset);
|
|
44
|
+
pending.forEach((timer) => clearTimeout(timer));
|
|
45
|
+
current.current = created.value; created.destroy(); engine.current = null;
|
|
46
|
+
};
|
|
47
|
+
// Callbacks and controlled value are read through refs.
|
|
48
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
49
|
+
}, [min, max, step, label, name, disabled, error, formatValue, waveform, bars]);
|
|
50
|
+
useIsomorphicLayoutEffect(() => {
|
|
51
|
+
if (value !== undefined) engine.current?.setValue(value, false);
|
|
52
|
+
});
|
|
53
|
+
useIsomorphicLayoutEffect(() => {
|
|
54
|
+
if (!instanceRef) return;
|
|
55
|
+
instanceRef.current = engine.current;
|
|
56
|
+
return () => { instanceRef.current = null; };
|
|
57
|
+
}, [instanceRef, min, max, step, label, name, disabled, error, formatValue, waveform, bars]);
|
|
58
|
+
return <div {...props} ref={root} className={['mk-equalizer-slider', className].filter(Boolean).join(' ')} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { reduced } from '../../core/reduced';
|
|
3
|
+
import { createScope, type Scope } from '../../core/scope';
|
|
4
|
+
import { inert, type Instance } from '../../core/types';
|
|
5
|
+
import { press, rollDigits } from '../../motion/micro';
|
|
6
|
+
|
|
7
|
+
export interface NumericOptions {
|
|
8
|
+
min?: number; max?: number; step?: number; value?: number; defaultValue?: number; label?: string;
|
|
9
|
+
name?: string; disabled?: boolean; error?: string; formatValue?: (value: number) => string;
|
|
10
|
+
onInput?: (value: number) => void; onChange?: (value: number) => void;
|
|
11
|
+
}
|
|
12
|
+
export interface NumericInstance extends Instance { readonly value: number; setValue(value: number, notify?: boolean): void; reset(notify?: boolean): void }
|
|
13
|
+
/** Private: changes the range or format in place, so the change can glide instead of remounting. */
|
|
14
|
+
export interface NumericRange extends NumericInstance { configure(next: { min?: number; max?: number; step?: number; format?: (value: number) => string; value?: number }, notify?: boolean): void }
|
|
15
|
+
export interface NumericParts { root: HTMLElement; track: HTMLElement; thumb: HTMLElement; scope: Scope; keep(effect: gsap.core.Animation | null): void }
|
|
16
|
+
export interface NumericDesign {
|
|
17
|
+
prefix: string; label: string; min: number; max: number; value: number;
|
|
18
|
+
format(value: number): string;
|
|
19
|
+
decorate?(parts: NumericParts): ((ratio: number, value: number, animate: boolean) => void) | void;
|
|
20
|
+
}
|
|
21
|
+
const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n);
|
|
22
|
+
let sequence = 0;
|
|
23
|
+
|
|
24
|
+
/** Private renderer: transparent native range over an immediate visual track. */
|
|
25
|
+
export function createNumericRange(root: HTMLElement | null, options: NumericOptions, design: NumericDesign): NumericRange {
|
|
26
|
+
if (!root) return inert({ value: design.value, setValue() {}, reset() {}, configure() {} });
|
|
27
|
+
registerMotion();
|
|
28
|
+
const scope = createScope();
|
|
29
|
+
const effects = new Set<gsap.core.Animation>();
|
|
30
|
+
const keep = (effect: gsap.core.Animation | null) => {
|
|
31
|
+
for (const prior of effects) if (prior.totalProgress() === 1) effects.delete(prior);
|
|
32
|
+
if (effect) effects.add(effect);
|
|
33
|
+
};
|
|
34
|
+
scope.add(() => { effects.forEach((effect) => effect.kill()); effects.clear(); });
|
|
35
|
+
let min = finite(options.min) ? options.min : design.min;
|
|
36
|
+
let max = finite(options.max) && options.max > min ? options.max : Math.max(min + 1, design.max);
|
|
37
|
+
let step = finite(options.step) && options.step > 0 ? options.step : 1;
|
|
38
|
+
let lastStep = Math.max(0, Math.floor((max - min) / step + 1e-8));
|
|
39
|
+
let format = options.formatValue ?? design.format;
|
|
40
|
+
const snap = (n: number) => Number((min + Math.max(0, Math.min(lastStep, Math.round((n - min) / step))) * step).toFixed(8));
|
|
41
|
+
const initial = snap(finite(options.defaultValue) ? options.defaultValue : finite(options.value) ? options.value : design.value);
|
|
42
|
+
let value = snap(finite(options.value) ? options.value : initial); let committed = value; let held = false;
|
|
43
|
+
const prefix = design.prefix;
|
|
44
|
+
const part = <K extends keyof HTMLElementTagNameMap>(tag: K, parent: Element, name: string) => {
|
|
45
|
+
const el = document.createElement(tag); el.setAttribute(`data-mk-${prefix}-${name}`, ''); parent.append(el); return el;
|
|
46
|
+
};
|
|
47
|
+
const shell = part('div', root, 'body');
|
|
48
|
+
scope.add(() => shell.remove());
|
|
49
|
+
const header = part('div', shell, 'header');
|
|
50
|
+
const label = part('label', header, 'label');
|
|
51
|
+
label.textContent = options.label ?? design.label;
|
|
52
|
+
const output = part('output', header, 'output');
|
|
53
|
+
output.setAttribute('aria-hidden', 'true');
|
|
54
|
+
const track = part('div', shell, 'track');
|
|
55
|
+
const thumb = part('span', track, 'thumb');
|
|
56
|
+
thumb.setAttribute('aria-hidden', 'true');
|
|
57
|
+
const input = part('input', track, 'input');
|
|
58
|
+
input.type = 'range'; input.min = String(min); input.max = String(max); input.step = String(step);
|
|
59
|
+
input.defaultValue = String(initial); input.value = String(value);
|
|
60
|
+
input.id = `mk-${prefix}-${++sequence}`; label.htmlFor = input.id;
|
|
61
|
+
input.disabled = Boolean(options.disabled);
|
|
62
|
+
if (options.name) input.name = options.name;
|
|
63
|
+
const message = part('span', shell, 'message');
|
|
64
|
+
message.textContent = options.error ?? '';
|
|
65
|
+
if (options.error) {
|
|
66
|
+
message.id = `${input.id}-error`; input.setAttribute('aria-invalid', 'true'); input.setAttribute('aria-describedby', message.id);
|
|
67
|
+
}
|
|
68
|
+
const ends = part('div', shell, 'ends');
|
|
69
|
+
ends.setAttribute('aria-hidden', 'true');
|
|
70
|
+
const minEnd = part('span', ends, 'min'); minEnd.textContent = format(min);
|
|
71
|
+
const maxEnd = part('span', ends, 'max'); maxEnd.textContent = format(max);
|
|
72
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
73
|
+
scope.attr(root, 'data-mk-disabled', options.disabled ? '' : null);
|
|
74
|
+
scope.attr(root, 'data-mk-error', options.error ? '' : null);
|
|
75
|
+
scope.attr(root, 'data-mk-held', null);
|
|
76
|
+
scope.attr(root, 'style', root.getAttribute('style'));
|
|
77
|
+
const decorate = design.decorate?.({ root, track, thumb, scope, keep });
|
|
78
|
+
let shown: number | null = null;
|
|
79
|
+
let glide: gsap.core.Tween | null = null;
|
|
80
|
+
scope.add(() => glide?.kill());
|
|
81
|
+
const show = (ratio: number, animate: boolean) => {
|
|
82
|
+
shown = ratio;
|
|
83
|
+
root.style.setProperty(`--mk-${prefix}-ratio`, String(ratio));
|
|
84
|
+
decorate?.(ratio, value, animate);
|
|
85
|
+
};
|
|
86
|
+
const paint = (animate: boolean, before: number) => {
|
|
87
|
+
const ratio = (value - min) / (max - min);
|
|
88
|
+
const text = format(value);
|
|
89
|
+
input.value = String(value); input.setAttribute('aria-valuetext', text);
|
|
90
|
+
if (animate && !held && !reduced()) keep(rollDigits(output, text, value >= before ? 1 : -1, 0.3));
|
|
91
|
+
else { const roll = rollDigits(output, text, value >= before ? 1 : -1, 0); roll?.progress(1).kill(); output.textContent = text; }
|
|
92
|
+
glide?.kill(); glide = null;
|
|
93
|
+
// Presets, keys and range changes glide on the kit's swatch spring; a held
|
|
94
|
+
// thumb follows the pointer exactly. The value itself is already final.
|
|
95
|
+
if (animate && !held && !reduced() && shown !== null && Math.abs(ratio - shown) > 1e-4) {
|
|
96
|
+
const proxy = { ratio: shown };
|
|
97
|
+
glide = gsap.to(proxy, { ratio, duration: 0.5, ease: EASE.swatch, onUpdate: () => show(Math.max(0, Math.min(1, proxy.ratio)), true) });
|
|
98
|
+
} else show(ratio, animate && !held);
|
|
99
|
+
};
|
|
100
|
+
const emit = (type: 'input' | 'change') => {
|
|
101
|
+
if (scope.disposed) return;
|
|
102
|
+
const emitted = value;
|
|
103
|
+
(type === 'input' ? options.onInput : options.onChange)?.(emitted);
|
|
104
|
+
if (!scope.disposed) root.dispatchEvent(new CustomEvent(`mk:${type}`, { bubbles: true, detail: { value: emitted } }));
|
|
105
|
+
};
|
|
106
|
+
const set = (next: number, notify = false, animate = true) => {
|
|
107
|
+
if (scope.disposed || !finite(next)) return;
|
|
108
|
+
const before = value; value = snap(next); paint(animate, before);
|
|
109
|
+
if (notify && value !== before) { emit('input'); committed = value; emit('change'); }
|
|
110
|
+
// A controlled onInput render commonly writes this same value back before
|
|
111
|
+
// the native change event. Keep its pending commit until release.
|
|
112
|
+
else if (!notify && value !== before) committed = value;
|
|
113
|
+
};
|
|
114
|
+
scope.on(input, 'input', () => {
|
|
115
|
+
if (input.disabled || input.matches(':disabled')) { input.value = String(value); return; }
|
|
116
|
+
const before = value; value = snap(Number(input.value)); paint(true, before);
|
|
117
|
+
if (value !== before) emit('input');
|
|
118
|
+
});
|
|
119
|
+
scope.on(input, 'change', () => {
|
|
120
|
+
if (input.disabled || input.matches(':disabled')) return;
|
|
121
|
+
const before = value; value = snap(Number(input.value));
|
|
122
|
+
if (value !== before) paint(true, before);
|
|
123
|
+
if (value !== committed) { committed = value; emit('change'); }
|
|
124
|
+
});
|
|
125
|
+
scope.on(input, 'pointerdown', () => {
|
|
126
|
+
if (input.disabled || input.matches(':disabled')) return;
|
|
127
|
+
held = true; root.setAttribute('data-mk-held', ''); keep(press(thumb));
|
|
128
|
+
});
|
|
129
|
+
const release = () => {
|
|
130
|
+
if (!held) return;
|
|
131
|
+
held = false; root.removeAttribute('data-mk-held');
|
|
132
|
+
if (!reduced()) keep(gsap.fromTo(thumb, { scale: 1.12 }, { scale: 1, duration: 0.4, ease: EASE.swatch, overwrite: 'auto', clearProps: 'transform' }));
|
|
133
|
+
};
|
|
134
|
+
scope.on(window, 'pointerup', release); scope.on(window, 'pointercancel', release); scope.on(input, 'blur', release);
|
|
135
|
+
if (input.form) scope.on(input.form, 'reset', (event) => scope.timeout(() => {
|
|
136
|
+
if (!scope.disposed && !event.defaultPrevented) set(initial);
|
|
137
|
+
}, 0));
|
|
138
|
+
const configure: NumericRange['configure'] = (next, notify = false) => {
|
|
139
|
+
if (scope.disposed) return;
|
|
140
|
+
const before = value;
|
|
141
|
+
if (finite(next.min)) min = next.min;
|
|
142
|
+
if (finite(next.max)) max = next.max > min ? next.max : min + 1;
|
|
143
|
+
if (finite(next.step) && next.step > 0) step = next.step;
|
|
144
|
+
if (next.format) format = next.format;
|
|
145
|
+
lastStep = Math.max(0, Math.floor((max - min) / step + 1e-8));
|
|
146
|
+
input.min = String(min); input.max = String(max); input.step = String(step);
|
|
147
|
+
const ends = [[minEnd, format(min)], [maxEnd, format(max)]] as const;
|
|
148
|
+
for (const [end, text] of ends) {
|
|
149
|
+
if (reduced()) end.textContent = text;
|
|
150
|
+
else keep(rollDigits(end, text, 1, 0.3));
|
|
151
|
+
}
|
|
152
|
+
value = snap(finite(next.value) ? next.value : value);
|
|
153
|
+
paint(true, before);
|
|
154
|
+
if (value !== before) {
|
|
155
|
+
committed = value;
|
|
156
|
+
if (notify) { emit('input'); emit('change'); }
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
paint(false, value);
|
|
160
|
+
return { get value() { return value; }, setValue: set, reset: (notify = false) => set(initial, notify), configure, destroy: () => scope.dispose() };
|
|
161
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Handwriting text
|
|
2
|
+
|
|
3
|
+
Una línea de tinta se escribe con trazos SVG originales. Cada trazo se dibuja mediante `stroke-dashoffset`; no es una animación de texto tecleado ni depende de una fuente externa. Incluye glifos latinos A–Z/a–z, cifras, puntuación básica y acentos agudo, diéresis y tilde. Los símbolos no incluidos se muestran como texto SVG normal, sin fingir que se escriben a mano. El texto completo conserva su nombre accesible.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createHandwritingText } from '@yewdiel21/motion-kit';
|
|
7
|
+
import '@yewdiel21/motion-kit/css/handwriting-text';
|
|
8
|
+
|
|
9
|
+
const drawing = createHandwritingText(document.querySelector('#signature'), {
|
|
10
|
+
text: 'Make it move.', duration: 2.4,
|
|
11
|
+
});
|
|
12
|
+
drawing.setText('Una nueva idea');
|
|
13
|
+
drawing.replay();
|
|
14
|
+
// Al desmontar: drawing.destroy();
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`HandwritingTextOptions`: `text` (por defecto el texto del host), `duration` (segundos, 2.4), `autoplay` (true), `onComplete`. `HandwritingTextInstance`: `setText(text, animate = true)`, `replay()`, `text`, `destroy()`. Emite `mk:complete` cuando termina una reproducción animada. Una cadena vacía produce un estado vacío; admite saltos de línea. Movimiento reducido muestra los trazos completos inmediatamente.
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
<HandwritingText text={message} instanceRef={drawingRef} />
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
El adaptador React controla `text`; recibe `instanceRef` y los atributos de un span. El motor posee los hijos, por lo que `children` y `dangerouslySetInnerHTML` no se admiten y se filtran también en ejecución.
|
|
24
|
+
|
|
25
|
+
```html
|
|
26
|
+
<mk-handwriting-text text="Make it move." duration="2.4"></mk-handwriting-text>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`defineHandwritingText()` registra `MkHandwritingTextElement`. Sus atributos son `text`, `duration` y `autoplay`; `element.instance` expone el motor. Conserva el texto imperativo al cambiar presentación o reconectar; un cambio explícito del atributo `text` lo reemplaza. CSS: `--mk-handwriting-width` (28rem); hereda `color` y todos los temas. `destroy()` detiene la reproducción y devuelve los nodos y atributos originales.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { MkElement, defineElement, type OptionType } from '../../elements/base';
|
|
2
|
+
import { createHandwritingText, type HandwritingTextInstance } from './engine';
|
|
3
|
+
|
|
4
|
+
export class MkHandwritingTextElement extends MkElement<HandwritingTextInstance> {
|
|
5
|
+
static options: Record<string, OptionType> = { text: 'string', duration: 'number', autoplay: 'boolean' };
|
|
6
|
+
private retained: string | undefined;
|
|
7
|
+
protected create(options: Record<string, unknown>) { return createHandwritingText(this, { ...options, ...(this.retained === undefined ? {} : { text: this.retained }) }); }
|
|
8
|
+
disconnectedCallback() { this.retained = this.instance?.text ?? this.retained; super.disconnectedCallback(); }
|
|
9
|
+
attributeChangedCallback(name: string, previous: string | null, next: string | null) {
|
|
10
|
+
if (previous !== next) this.retained = name === 'text' ? undefined : this.instance?.text ?? this.retained;
|
|
11
|
+
super.attributeChangedCallback(name, previous, next);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export const defineHandwritingText = () => defineElement('mk-handwriting-text', MkHandwritingTextElement);
|