@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.
Files changed (181) hide show
  1. package/README.md +240 -199
  2. package/dist/auto/index.d.ts +19 -3
  3. package/dist/auto/index.js +35 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-QFBOHPWZ.js → chunk-CQP66AZK.js} +2 -2
  6. package/dist/{chunk-QP4QJOEN.js → chunk-H4STLMCM.js} +2 -2
  7. package/dist/{chunk-BZLMCHUD.js → chunk-KDIJX6G4.js} +3 -3
  8. package/dist/{chunk-DOOMULVV.js → chunk-OLT3CWDJ.js} +4 -2
  9. package/dist/chunk-OLT3CWDJ.js.map +1 -0
  10. package/dist/{chunk-VJFPTLAK.js → chunk-PEDYJ2TV.js} +8 -22
  11. package/dist/chunk-PEDYJ2TV.js.map +1 -0
  12. package/dist/{chunk-7IQTMF7Y.js → chunk-Y4GIBTRG.js} +5130 -2153
  13. package/dist/chunk-Y4GIBTRG.js.map +1 -0
  14. package/dist/css/components/badge-button.css +16 -0
  15. package/dist/css/components/bento-grid.css +13 -0
  16. package/dist/css/components/code-block.css +267 -0
  17. package/dist/css/components/command-search.css +274 -2
  18. package/dist/css/components/dialog.css +264 -0
  19. package/dist/css/components/drilldown-menu.css +11 -7
  20. package/dist/css/components/equalizer-slider.css +26 -0
  21. package/dist/css/components/handwriting-text.css +15 -0
  22. package/dist/css/components/heatmap-chart.css +267 -1
  23. package/dist/css/components/magnetize-button.css +18 -0
  24. package/dist/css/components/morphing-page-dots.css +13 -0
  25. package/dist/css/components/not-found.css +22 -0
  26. package/dist/css/components/notification-stack.css +42 -0
  27. package/dist/css/components/particle-button.css +14 -0
  28. package/dist/css/components/radio-group.css +29 -0
  29. package/dist/css/components/rating-scale.css +23 -0
  30. package/dist/css/components/scroll-area.css +261 -0
  31. package/dist/css/components/scroll-preview-dock.css +18 -0
  32. package/dist/css/components/select.css +300 -4
  33. package/dist/css/components/signature-pad.css +130 -103
  34. package/dist/css/components/stepper.css +36 -0
  35. package/dist/css/components/sticky-header.css +10 -0
  36. package/dist/css/components/temperature-slider.css +29 -0
  37. package/dist/css/components/voice-message-bubble.css +62 -45
  38. package/dist/css/components/waveform-player.css +49 -34
  39. package/dist/css/components/wheel-picker.css +16 -15
  40. package/dist/css/tokens.css +4 -0
  41. package/dist/elements/index.d.ts +217 -4
  42. package/dist/elements/index.js +614 -9
  43. package/dist/elements/index.js.map +1 -1
  44. package/dist/{engine-Bn6hC87X.d.ts → engine-D6XV-WNt.d.ts} +390 -1
  45. package/dist/gl/index.js +2 -2
  46. package/dist/index.css +886 -177
  47. package/dist/index.d.ts +23 -23
  48. package/dist/index.js +37 -5
  49. package/dist/index.js.map +1 -1
  50. package/dist/react/index.d.ts +126 -7
  51. package/dist/react/index.js +722 -7
  52. package/dist/react/index.js.map +1 -1
  53. package/dist/{reduced-YS6TZjpa.d.ts → reduced-4croIXl-.d.ts} +1 -1
  54. package/dist/{store-CZ6vdtIE.d.ts → store-CAHWkurn.d.ts} +1 -1
  55. package/package.json +110 -110
  56. package/registry.json +1357 -1160
  57. package/src/auto/auto-init.ts +341 -308
  58. package/src/components/badge-button/README.md +28 -0
  59. package/src/components/badge-button/badge-button.css +15 -0
  60. package/src/components/badge-button/element.ts +16 -0
  61. package/src/components/badge-button/engine.ts +85 -0
  62. package/src/components/badge-button/react.tsx +31 -0
  63. package/src/components/bento-grid/README.md +26 -0
  64. package/src/components/bento-grid/bento-grid.css +12 -0
  65. package/src/components/bento-grid/element.ts +22 -0
  66. package/src/components/bento-grid/engine.ts +153 -0
  67. package/src/components/bento-grid/react.tsx +25 -0
  68. package/src/components/code-block/README.md +1 -1
  69. package/src/components/code-block/code-block.css +5 -0
  70. package/src/components/code-block/engine.ts +5 -0
  71. package/src/components/command-search/README.md +1 -1
  72. package/src/components/command-search/command-search.css +12 -2
  73. package/src/components/command-search/engine.ts +6 -0
  74. package/src/components/dialog/README.md +104 -103
  75. package/src/components/dialog/dialog.css +2 -0
  76. package/src/components/dialog/engine.ts +465 -449
  77. package/src/components/drilldown-menu/drilldown-menu.css +11 -7
  78. package/src/components/drilldown-menu/engine.ts +44 -4
  79. package/src/components/equalizer-slider/README.md +43 -0
  80. package/src/components/equalizer-slider/element.ts +41 -0
  81. package/src/components/equalizer-slider/engine.ts +32 -0
  82. package/src/components/equalizer-slider/equalizer-slider.css +26 -0
  83. package/src/components/equalizer-slider/react.tsx +60 -0
  84. package/src/components/equalizer-slider/shared.ts +161 -0
  85. package/src/components/handwriting-text/README.md +29 -0
  86. package/src/components/handwriting-text/element.ts +14 -0
  87. package/src/components/handwriting-text/engine.ts +108 -0
  88. package/src/components/handwriting-text/glyphs.ts +71 -0
  89. package/src/components/handwriting-text/handwriting-text.css +14 -0
  90. package/src/components/handwriting-text/react.tsx +23 -0
  91. package/src/components/heatmap-chart/README.md +48 -48
  92. package/src/components/heatmap-chart/engine.ts +6 -1
  93. package/src/components/heatmap-chart/heatmap-chart.css +5 -1
  94. package/src/components/icons.ts +1 -0
  95. package/src/components/magnetize-button/README.md +26 -0
  96. package/src/components/magnetize-button/element.ts +13 -0
  97. package/src/components/magnetize-button/engine.ts +84 -0
  98. package/src/components/magnetize-button/magnetize-button.css +17 -0
  99. package/src/components/magnetize-button/react.tsx +22 -0
  100. package/src/components/morphing-page-dots/README.md +28 -0
  101. package/src/components/morphing-page-dots/element.ts +16 -0
  102. package/src/components/morphing-page-dots/engine.ts +89 -0
  103. package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
  104. package/src/components/morphing-page-dots/react.tsx +32 -0
  105. package/src/components/not-found/README.md +26 -0
  106. package/src/components/not-found/element.ts +15 -0
  107. package/src/components/not-found/engine.ts +57 -0
  108. package/src/components/not-found/not-found.css +21 -0
  109. package/src/components/not-found/react.tsx +23 -0
  110. package/src/components/notification-stack/README.md +24 -0
  111. package/src/components/notification-stack/element.ts +26 -0
  112. package/src/components/notification-stack/engine.ts +329 -0
  113. package/src/components/notification-stack/notification-stack.css +41 -0
  114. package/src/components/notification-stack/react.tsx +28 -0
  115. package/src/components/particle-button/README.md +26 -0
  116. package/src/components/particle-button/element.ts +13 -0
  117. package/src/components/particle-button/engine.ts +52 -0
  118. package/src/components/particle-button/particle-button.css +13 -0
  119. package/src/components/particle-button/react.tsx +22 -0
  120. package/src/components/radio-group/README.md +43 -0
  121. package/src/components/radio-group/element.ts +41 -0
  122. package/src/components/radio-group/engine.ts +11 -0
  123. package/src/components/radio-group/radio-group.css +28 -0
  124. package/src/components/radio-group/react.tsx +60 -0
  125. package/src/components/radio-group/shared.ts +118 -0
  126. package/src/components/rating-scale/README.md +43 -0
  127. package/src/components/rating-scale/element.ts +41 -0
  128. package/src/components/rating-scale/engine.ts +63 -0
  129. package/src/components/rating-scale/rating-scale.css +22 -0
  130. package/src/components/rating-scale/react.tsx +60 -0
  131. package/src/components/scroll-area/README.md +89 -0
  132. package/src/components/scroll-area/element.ts +35 -0
  133. package/src/components/scroll-area/engine.ts +528 -0
  134. package/src/components/scroll-area/react.tsx +67 -0
  135. package/src/components/scroll-area/scroll-area.css +260 -0
  136. package/src/components/scroll-preview-dock/README.md +24 -0
  137. package/src/components/scroll-preview-dock/element.ts +20 -0
  138. package/src/components/scroll-preview-dock/engine.ts +228 -0
  139. package/src/components/scroll-preview-dock/react.tsx +26 -0
  140. package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
  141. package/src/components/select/README.md +23 -9
  142. package/src/components/select/engine.ts +16 -4
  143. package/src/components/select/select.css +38 -4
  144. package/src/components/signature-pad/engine.ts +550 -437
  145. package/src/components/signature-pad/signature-pad.css +130 -103
  146. package/src/components/stepper/README.md +46 -0
  147. package/src/components/stepper/element.ts +42 -0
  148. package/src/components/stepper/engine.ts +103 -0
  149. package/src/components/stepper/react.tsx +60 -0
  150. package/src/components/stepper/stepper.css +35 -0
  151. package/src/components/sticky-header/README.md +30 -0
  152. package/src/components/sticky-header/element.ts +19 -0
  153. package/src/components/sticky-header/engine.ts +95 -0
  154. package/src/components/sticky-header/react.tsx +25 -0
  155. package/src/components/sticky-header/sticky-header.css +9 -0
  156. package/src/components/temperature-slider/README.md +42 -0
  157. package/src/components/temperature-slider/element.ts +53 -0
  158. package/src/components/temperature-slider/engine.ts +44 -0
  159. package/src/components/temperature-slider/react.tsx +66 -0
  160. package/src/components/temperature-slider/temperature-slider.css +29 -0
  161. package/src/components/voice-message-bubble/engine.ts +11 -0
  162. package/src/components/voice-message-bubble/voice-message-bubble.css +14 -12
  163. package/src/components/waveform-player/README.md +1 -1
  164. package/src/components/waveform-player/engine.ts +344 -261
  165. package/src/components/waveform-player/waveform-player.css +50 -35
  166. package/src/components/wheel-picker/README.md +3 -1
  167. package/src/components/wheel-picker/element.ts +12 -5
  168. package/src/components/wheel-picker/engine.ts +37 -8
  169. package/src/components/wheel-picker/wheel-picker.css +17 -16
  170. package/src/core/timing.ts +2 -0
  171. package/src/css/tokens.css +4 -0
  172. package/src/elements/index.ts +321 -273
  173. package/src/index.ts +312 -295
  174. package/src/motion/morph.ts +5 -2
  175. package/src/react/index.ts +17 -0
  176. package/dist/chunk-7IQTMF7Y.js.map +0 -1
  177. package/dist/chunk-DOOMULVV.js.map +0 -1
  178. package/dist/chunk-VJFPTLAK.js.map +0 -1
  179. /package/dist/{chunk-QFBOHPWZ.js.map → chunk-CQP66AZK.js.map} +0 -0
  180. /package/dist/{chunk-QP4QJOEN.js.map → chunk-H4STLMCM.js.map} +0 -0
  181. /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: 10px;
12
- border: 1px solid var(--mk-outline-soft);
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
- box-shadow: 0 12px 36px -20px rgb(0 0 0 / 0.25);
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-level] { display: grid; gap: 3px; }
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: background-color 0.16s ease;
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-surface-3); }
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
- viewport.append(menu, empty);
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);