@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
@@ -0,0 +1,108 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { inert, type Instance } from '../../core/types';
5
+ import { GLYPHS } from './glyphs';
6
+
7
+ export interface HandwritingTextOptions {
8
+ text?: string;
9
+ /** Duration of the complete pen drawing in seconds, default 2.4. */
10
+ duration?: number;
11
+ autoplay?: boolean;
12
+ onComplete?: () => void;
13
+ }
14
+ export interface HandwritingTextInstance extends Instance {
15
+ setText(text: string, animate?: boolean): void;
16
+ replay(): void;
17
+ readonly text: string;
18
+ }
19
+
20
+ const SVG = 'http://www.w3.org/2000/svg';
21
+ export function createHandwritingText(root: HTMLElement | null, options: HandwritingTextOptions = {}): HandwritingTextInstance {
22
+ if (!root) return inert({ setText() {}, replay() {}, text: '' });
23
+ registerMotion();
24
+ const scope = createScope();
25
+ let text = options.text ?? root.textContent ?? '';
26
+ const authored = [...root.childNodes];
27
+ const svg = document.createElementNS(SVG, 'svg');
28
+ svg.setAttribute('aria-hidden', 'true');
29
+ svg.dataset.mkHandwritingDrawing = '';
30
+ root.replaceChildren(svg);
31
+ scope.add(() => { svg.remove(); root.append(...authored); });
32
+ scope.attr(root, 'data-mk-handwriting', '');
33
+ scope.attr(root, 'data-mk-ready', '');
34
+ scope.attr(root, 'role', 'img');
35
+ let strokes: SVGPathElement[] = [];
36
+ let timeline: gsap.core.Timeline | null = null;
37
+ const duration = Number.isFinite(options.duration) ? Math.max(0.1, Math.min(30, options.duration!)) : 2.4;
38
+ const complete = () => {
39
+ if (scope.disposed) return;
40
+ options.onComplete?.();
41
+ root.dispatchEvent(new CustomEvent('mk:complete', { bubbles: true }));
42
+ };
43
+ const replay = () => {
44
+ if (scope.disposed) return;
45
+ timeline?.kill();
46
+ timeline = null;
47
+ if (reduced() || !strokes.length) { gsap.set(strokes, { strokeDashoffset: 0 }); return; }
48
+ gsap.set(strokes, { strokeDashoffset: 1 });
49
+ timeline = gsap.timeline({ onComplete: complete });
50
+ // Each pen lift is explicit: the paths draw sequentially, never by revealing typed characters.
51
+ const step = duration / strokes.length;
52
+ for (const stroke of strokes) timeline.to(stroke, { strokeDashoffset: 0, duration: step, ease: EASE.wipe });
53
+ };
54
+ const draw = () => {
55
+ timeline?.kill();
56
+ timeline = null;
57
+ strokes = [];
58
+ svg.replaceChildren();
59
+ scope.attr(root, 'aria-label', text);
60
+ scope.attr(root, 'data-mk-empty', text ? null : '');
61
+ let x = 5;
62
+ let y = 6;
63
+ let width = 10;
64
+ for (const char of text) {
65
+ if (char === '\n') { width = Math.max(width, x + 5); x = 5; y += 64; continue; }
66
+ if (/\s/u.test(char)) { x += 14; continue; }
67
+ const normalized = char.normalize('NFD');
68
+ const glyph = GLYPHS[normalized[0]!];
69
+ if (glyph) {
70
+ const paths = [...glyph.strokes];
71
+ if (normalized.includes('\u0301')) paths.push('M16 14L23 7');
72
+ if (normalized.includes('\u0303')) paths.push('M11 14Q16 8 20 13Q24 17 28 10');
73
+ if (normalized.includes('\u0308')) paths.push('M12 12L12.3 12.3M23 12L23.3 12.3');
74
+ for (const d of paths) {
75
+ const path = document.createElementNS(SVG, 'path');
76
+ path.setAttribute('d', d);
77
+ path.setAttribute('transform', `translate(${x} ${y})`);
78
+ path.setAttribute('pathLength', '1');
79
+ path.setAttribute('stroke-dasharray', '1');
80
+ path.setAttribute('stroke-dashoffset', '0');
81
+ svg.append(path);
82
+ strokes.push(path);
83
+ }
84
+ x += glyph.width;
85
+ } else {
86
+ // Unsupported symbols remain honest, readable SVG text; they are never claimed as pen strokes.
87
+ const fallback = document.createElementNS(SVG, 'text');
88
+ fallback.textContent = char;
89
+ fallback.setAttribute('x', String(x));
90
+ fallback.setAttribute('y', String(y + 42));
91
+ fallback.setAttribute('data-mk-handwriting-fallback', '');
92
+ svg.append(fallback);
93
+ x += 34;
94
+ }
95
+ }
96
+ svg.setAttribute('viewBox', `0 0 ${Math.max(width, x + 8)} ${y + 64}`);
97
+ svg.setAttribute('preserveAspectRatio', 'xMinYMid meet');
98
+ };
99
+ draw();
100
+ if (options.autoplay !== false) replay();
101
+ scope.add(() => { timeline?.kill(); gsap.killTweensOf(strokes); });
102
+ return {
103
+ replay,
104
+ setText(next, animate = true) { if (scope.disposed || next === text) return; text = String(next); draw(); if (animate) replay(); },
105
+ get text() { return text; },
106
+ destroy: () => scope.dispose(),
107
+ };
108
+ }
@@ -0,0 +1,71 @@
1
+ /** Original monoline pen drawings. Coordinates use a 44-unit baseline; no font outlines. */
2
+ export const GLYPHS: Record<string, { width: number; strokes: string[] }> = {
3
+ A: { width: 35, strokes: ['M2 43L26 5L27 42', 'M10 30L31 28'] },
4
+ B: { width: 34, strokes: ['M4 43L14 7', 'M10 8C40 -2 35 25 11 25C40 14 37 45 4 43'] },
5
+ C: { width: 34, strokes: ['M33 12C18 -8 -3 20 5 36Q12 52 31 35'] },
6
+ D: { width: 35, strokes: ['M5 43L15 7', 'M10 7C46 -3 40 48 4 43'] },
7
+ E: { width: 30, strokes: ['M31 7L13 9L4 42L29 40', 'M10 25L27 23'] },
8
+ F: { width: 29, strokes: ['M4 43L14 8L32 7', 'M10 25L27 24'] },
9
+ G: { width: 36, strokes: ['M33 12C15 -8 -4 27 7 39Q21 51 31 26L19 28'] },
10
+ H: { width: 36, strokes: ['M4 43L14 7', 'M28 43L37 7', 'M8 27L32 24'] },
11
+ I: { width: 20, strokes: ['M17 8L8 42', 'M9 9L25 7', 'M1 43L17 41'] },
12
+ J: { width: 28, strokes: ['M19 8L32 7L24 33Q17 54 3 38'] },
13
+ K: { width: 34, strokes: ['M4 43L14 7', 'M35 7L10 29L29 43'] },
14
+ L: { width: 28, strokes: ['M14 7L4 42Q15 39 29 41'] },
15
+ M: { width: 47, strokes: ['M3 43L15 7L22 35L44 7L35 43'] },
16
+ N: { width: 37, strokes: ['M4 43L14 7L28 42L38 7'] },
17
+ O: { width: 36, strokes: ['M25 7C5 1 -5 43 15 43C35 46 46 3 25 7Z'] },
18
+ P: { width: 32, strokes: ['M4 43L14 8C40 -2 39 30 8 27'] },
19
+ Q: { width: 37, strokes: ['M25 7C5 1 -5 43 15 43C35 46 46 3 25 7Z', 'M19 33L33 48'] },
20
+ R: { width: 34, strokes: ['M4 43L14 8C40 -2 39 30 8 27L29 44'] },
21
+ S: { width: 31, strokes: ['M31 12C19 -5 -4 16 16 25C39 35 12 55 2 36'] },
22
+ T: { width: 31, strokes: ['M3 10L34 7', 'M20 9L11 43'] },
23
+ U: { width: 36, strokes: ['M13 7L5 31C-1 52 27 48 31 29L37 7'] },
24
+ V: { width: 35, strokes: ['M7 7L10 43L37 7'] },
25
+ W: { width: 48, strokes: ['M7 7L9 43L27 15L28 43L50 7'] },
26
+ X: { width: 34, strokes: ['M10 7L28 43', 'M36 7L2 43'] },
27
+ Y: { width: 34, strokes: ['M8 7L19 27L37 7', 'M19 27L14 43'] },
28
+ Z: { width: 32, strokes: ['M7 9L34 7L2 43L29 41'] },
29
+ a: { width: 27, strokes: ['M23 23C9 12 2 40 13 42C21 44 26 21 23 22L20 39Q20 47 29 37'] },
30
+ b: { width: 27, strokes: ['M4 42Q21 -6 13 3Q5 7 7 36C19 7 34 30 19 40Q11 46 7 38L29 36'] },
31
+ c: { width: 25, strokes: ['M23 24C17 13 3 26 6 37Q10 49 27 35'] },
32
+ d: { width: 28, strokes: ['M22 24C8 12 0 39 11 42Q20 46 29 4Q31 -1 27 3L21 38Q21 46 31 35'] },
33
+ e: { width: 25, strokes: ['M5 33Q25 29 21 21Q17 13 8 27C-1 43 15 49 27 35'] },
34
+ f: { width: 23, strokes: ['M2 57Q1 63 7 52L22 7Q24 -3 16 5Q7 17 11 27L24 24', 'M3 27L20 26'] },
35
+ g: { width: 28, strokes: ['M24 24C10 12 1 39 12 42Q20 45 24 23L18 54Q12 68 6 57Q3 48 31 36'] },
36
+ h: { width: 28, strokes: ['M4 42Q24 -6 14 4Q8 10 5 41C17 9 26 18 21 34Q17 49 31 35'] },
37
+ i: { width: 15, strokes: ['M9 24L5 36Q2 47 18 35', 'M12 13L12.2 13.2'] },
38
+ j: { width: 18, strokes: ['M13 24L6 53Q1 68 -4 56Q-6 48 21 35', 'M16 13L16.2 13.2'] },
39
+ k: { width: 27, strokes: ['M4 42Q24 -7 14 4Q8 10 5 41', 'M25 22Q8 38 10 32Q17 27 19 37Q21 45 30 35'] },
40
+ l: { width: 18, strokes: ['M3 32Q28 -4 15 3Q7 8 6 31Q3 49 21 35'] },
41
+ m: { width: 40, strokes: ['M3 41L9 23L5 39Q18 11 20 25L17 39Q31 11 32 25L29 36Q28 46 43 35'] },
42
+ n: { width: 28, strokes: ['M3 41L9 23L5 39Q21 10 23 25L20 36Q19 46 31 35'] },
43
+ o: { width: 27, strokes: ['M22 22C7 14 0 39 12 42Q22 46 26 28Q26 18 19 24Q15 33 30 32'] },
44
+ p: { width: 28, strokes: ['M-1 58L11 22L7 35Q19 12 25 25Q29 40 14 42L8 39L31 35'] },
45
+ q: { width: 28, strokes: ['M23 24C10 12 1 39 12 42Q22 45 24 23L17 58Q15 62 19 53L31 36'] },
46
+ r: { width: 23, strokes: ['M2 41L9 22Q8 31 16 24Q20 18 21 23Q21 27 17 28L26 35'] },
47
+ s: { width: 24, strokes: ['M22 21Q18 15 10 24Q6 28 17 33Q25 39 14 43Q3 46 6 38L27 35'] },
48
+ t: { width: 21, strokes: ['M16 10L7 35Q3 48 24 35', 'M2 25L23 23'] },
49
+ u: { width: 29, strokes: ['M9 23L4 35Q1 52 24 24L20 38Q21 45 32 35'] },
50
+ v: { width: 26, strokes: ['M6 23Q-1 58 22 27Q25 20 21 23Q19 31 29 31'] },
51
+ w: { width: 39, strokes: ['M7 23Q-1 56 19 27Q10 56 34 27Q37 20 33 23Q31 31 42 31'] },
52
+ x: { width: 25, strokes: ['M7 24Q14 28 17 39Q20 45 28 35', 'M24 23L3 42'] },
53
+ y: { width: 28, strokes: ['M8 23L4 35Q2 50 24 24L16 54Q11 67 5 58Q0 50 31 36'] },
54
+ z: { width: 25, strokes: ['M6 25Q16 19 24 24L5 41Q14 36 20 43Q25 51 10 59Q1 63 5 54L28 36'] },
55
+ '0': { width: 29, strokes: ['M23 8C4 0 -2 42 13 43C28 44 34 7 23 8Z'] },
56
+ '1': { width: 20, strokes: ['M5 17L17 8L9 42', 'M2 43L18 43'] },
57
+ '2': { width: 28, strokes: ['M4 17C10 -2 35 7 23 22L3 42Q14 38 28 42'] },
58
+ '3': { width: 27, strokes: ['M6 11Q32 0 23 20L13 25Q34 17 24 36Q17 48 3 39'] },
59
+ '4': { width: 29, strokes: ['M20 7L2 31L30 29', 'M25 9L17 43'] },
60
+ '5': { width: 28, strokes: ['M29 8L10 9L5 25Q27 14 25 32Q20 49 3 39'] },
61
+ '6': { width: 28, strokes: ['M25 8C7 -1 -6 43 13 43C30 43 29 16 13 25L5 33'] },
62
+ '7': { width: 27, strokes: ['M4 10L29 9L9 43', 'M9 26L24 25'] },
63
+ '8': { width: 28, strokes: ['M18 24C-6 12 23 -2 27 12Q28 21 10 28C-5 37 13 52 24 38Q33 27 18 24Z'] },
64
+ '9': { width: 28, strokes: ['M24 20Q5 38 4 21C4 3 31 1 25 21L14 44'] },
65
+ '.': { width: 12, strokes: ['M6 41L6.4 41.4'] },
66
+ ',': { width: 12, strokes: ['M8 40L4 49'] },
67
+ '!': { width: 13, strokes: ['M12 5L6 31', 'M5 41L5.4 41.4'] },
68
+ '?': { width: 25, strokes: ['M3 14Q10 0 23 10Q29 18 16 24L11 31', 'M9 42L9.4 42.4'] },
69
+ '-': { width: 23, strokes: ['M4 28L22 27'] },
70
+ "'": { width: 11, strokes: ['M9 7L6 15'] },
71
+ };
@@ -0,0 +1,14 @@
1
+ .mk-handwriting-text,
2
+ mk-handwriting-text {
3
+ --mk-handwriting-width: 28rem;
4
+ display: inline-block;
5
+ width: min(100%, var(--mk-handwriting-width));
6
+ color: var(--mk-text);
7
+ font-family: var(--mk-font);
8
+ line-height: 1;
9
+ }
10
+ [data-mk-handwriting] [data-mk-handwriting-drawing] { display: block; width: 100%; height: auto; overflow: visible; }
11
+ [data-mk-handwriting] path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
12
+ [data-mk-handwriting-fallback] { fill: currentColor; stroke: none; font: 30px var(--mk-font); }
13
+ [data-mk-handwriting][data-mk-empty] { min-height: 1rem; }
14
+ @media (prefers-reduced-motion: reduce) { [data-mk-handwriting] path { stroke-dashoffset: 0 !important; } }
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type Ref } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createHandwritingText, type HandwritingTextInstance, type HandwritingTextOptions } from './engine';
5
+
6
+ export interface HandwritingTextProps extends Omit<HTMLAttributes<HTMLSpanElement>, 'children' | 'dangerouslySetInnerHTML' | 'onComplete'>, HandwritingTextOptions { instanceRef?: Ref<HandwritingTextInstance>; }
7
+ export function HandwritingText({ text = 'make it move.', duration, autoplay, onComplete, instanceRef, className, ...props }: HandwritingTextProps) {
8
+ const { children: _children, dangerouslySetInnerHTML: _html, ...safe } = props as HTMLAttributes<HTMLSpanElement>;
9
+ const root = useRef<HTMLSpanElement>(null);
10
+ const instance = useRef<HandwritingTextInstance | null>(null);
11
+ const latest = useLatest({ text, onComplete });
12
+ useIsomorphicLayoutEffect(() => {
13
+ const created = createHandwritingText(root.current, { text: latest.current.text, duration, autoplay, onComplete: () => latest.current.onComplete?.() });
14
+ instance.current = created;
15
+ return () => { created.destroy(); instance.current = null; };
16
+ }, [duration, autoplay]);
17
+ useIsomorphicLayoutEffect(() => { instance.current?.setText(text, autoplay !== false); }, [text, autoplay]);
18
+ useIsomorphicLayoutEffect(() => {
19
+ if (typeof instanceRef === 'function') instanceRef(instance.current); else if (instanceRef) instanceRef.current = instance.current;
20
+ return () => { if (typeof instanceRef === 'function') instanceRef(null); else if (instanceRef) instanceRef.current = null; };
21
+ }, [instanceRef, duration, autoplay]);
22
+ return <span {...safe} ref={root} role="img" aria-label={text} className={['mk-handwriting-text', className].filter(Boolean).join(' ')} />;
23
+ }
@@ -1,48 +1,48 @@
1
- # heatmap-chart
2
-
3
- Calendario de actividad tipo GitHub con intensidad por día, tooltips y desplazamiento horizontal.
4
-
5
- ## Instalación
6
-
7
- ```tsx
8
- import '@yewdiel21/motion-kit/css';
9
- import { HeatmapChart } from '@yewdiel21/motion-kit/react';
10
- ```
11
-
12
- ```sh
13
- npx @yewdiel21/motion-kit add heatmap-chart --react --elements
14
- ```
15
-
16
- ## React
17
-
18
- ```tsx
19
- <HeatmapChart end="2026-10-31" weeks={26} data={{ "2026-10-30": 4, "2026-10-31": 8 }} unit="sesiones" locale="es-MX" />
20
- ```
21
-
22
- Los callbacks del ejemplo (`ask`, `run`, `setTool`, `setVolume`) pertenecen a tu aplicación. React acepta `className`, `style` y atributos HTML. Los componentes con estado exponen `instanceRef` (excepto PillButton).
23
-
24
- ## HTML / Astro
25
-
26
- ```html
27
- <mk-heatmap-chart end="2026-10-31" weeks="26" data='{"2026-10-30":4,"2026-10-31":8}'></mk-heatmap-chart>
28
- <script type="module">
29
- import { defineElements } from '@yewdiel21/motion-kit/elements';
30
- defineElements(['mk-heatmap-chart']);
31
- </script>
32
- ```
33
-
34
- En Astro usa un `<script>` procesado por Astro. En HTML directo el import requiere bundler o import map. También puedes usar `import { createHeatmapChart } from '@yewdiel21/motion-kit'` y llamar a la factory con el contenedor y las mismas opciones. Al desmontar, llama a `instance.destroy()`.
35
-
36
- ## Opciones
37
-
38
- data: {date, value}[] o mapa YYYY-MM-DD a valor. end: hoy. weeks: 53. firstDay: 1 (lunes). levels: 4. color: acento. locale, unit, legend. onSelect(date, value). Instancia: select(date | null), update(options), selected.
39
-
40
- Todos los gráficos aceptan `label`, `emptyLabel` y `formatValue`. Se actualizan con `update(options)` sin desmontarse; `select(index | null)` y `selected` controlan la selección (en heatmap se usa una fecha). `onSelect` también emite `mk:select`.
41
-
42
- Los nombres camelCase se escriben como atributos kebab-case. Los arrays y objetos se pasan como JSON. Los booleanos aceptan atributo vacío o `"false"`.
43
-
44
- ## Estilos y accesibilidad
45
-
46
- Variables: --mk-heatmap-color, --mk-heatmap-empty; --mk-chart-surface.
47
-
48
- Respeta los colores del tema, foco visible y `prefers-reduced-motion`. Los gráficos incluyen una tabla accesible y selección con flechas, Home, End y Escape. Heatmap usa arriba/abajo para un día e izquierda/derecha para una semana. Toolbar usa flechas y Home/End; OrbitMenu cierra con Escape y devuelve el foco. Los controles conservan nombres accesibles; VoiceDots anuncia los estados sin leer cada fotograma.
1
+ # heatmap-chart
2
+
3
+ Calendario de actividad tipo GitHub con intensidad por día, tooltips y desplazamiento horizontal. Las semanas se desplazan con la barra de [`scroll-area`](../scroll-area/README.md), que tiene su propio carril bajo la última fila.
4
+
5
+ ## Instalación
6
+
7
+ ```tsx
8
+ import '@yewdiel21/motion-kit/css';
9
+ import { HeatmapChart } from '@yewdiel21/motion-kit/react';
10
+ ```
11
+
12
+ ```sh
13
+ npx @yewdiel21/motion-kit add heatmap-chart --react --elements
14
+ ```
15
+
16
+ ## React
17
+
18
+ ```tsx
19
+ <HeatmapChart end="2026-10-31" weeks={26} data={{ "2026-10-30": 4, "2026-10-31": 8 }} unit="sesiones" locale="es-MX" />
20
+ ```
21
+
22
+ Los callbacks del ejemplo (`ask`, `run`, `setTool`, `setVolume`) pertenecen a tu aplicación. React acepta `className`, `style` y atributos HTML. Los componentes con estado exponen `instanceRef` (excepto PillButton).
23
+
24
+ ## HTML / Astro
25
+
26
+ ```html
27
+ <mk-heatmap-chart end="2026-10-31" weeks="26" data='{"2026-10-30":4,"2026-10-31":8}'></mk-heatmap-chart>
28
+ <script type="module">
29
+ import { defineElements } from '@yewdiel21/motion-kit/elements';
30
+ defineElements(['mk-heatmap-chart']);
31
+ </script>
32
+ ```
33
+
34
+ En Astro usa un `<script>` procesado por Astro. En HTML directo el import requiere bundler o import map. También puedes usar `import { createHeatmapChart } from '@yewdiel21/motion-kit'` y llamar a la factory con el contenedor y las mismas opciones. Al desmontar, llama a `instance.destroy()`.
35
+
36
+ ## Opciones
37
+
38
+ data: {date, value}[] o mapa YYYY-MM-DD a valor. end: hoy. weeks: 53. firstDay: 1 (lunes). levels: 4. color: acento. locale, unit, legend. onSelect(date, value). Instancia: select(date | null), update(options), selected.
39
+
40
+ Todos los gráficos aceptan `label`, `emptyLabel` y `formatValue`. Se actualizan con `update(options)` sin desmontarse; `select(index | null)` y `selected` controlan la selección (en heatmap se usa una fecha). `onSelect` también emite `mk:select`.
41
+
42
+ Los nombres camelCase se escriben como atributos kebab-case. Los arrays y objetos se pasan como JSON. Los booleanos aceptan atributo vacío o `"false"`.
43
+
44
+ ## Estilos y accesibilidad
45
+
46
+ Variables: --mk-heatmap-color, --mk-heatmap-empty; --mk-chart-surface.
47
+
48
+ Respeta los colores del tema, foco visible y `prefers-reduced-motion`. Los gráficos incluyen una tabla accesible y selección con flechas, Home, End y Escape. Heatmap usa arriba/abajo para un día e izquierda/derecha para una semana. Toolbar usa flechas y Home/End; OrbitMenu cierra con Escape y devuelve el foco. Los controles conservan nombres accesibles; VoiceDots anuncia los estados sin leer cada fotograma.
@@ -4,6 +4,7 @@ import { reduced } from '../../core/reduced';
4
4
  import { createScope } from '../../core/scope';
5
5
  import { inert, type Instance } from '../../core/types';
6
6
  import { chartFrame, finite, formatNumber, onWidth, round, svg } from '../chart/parts';
7
+ import { createScrollArea } from '../scroll-area/engine';
7
8
 
8
9
  /*
9
10
  * Heatmap chart: daily activity on a calendar, a column per week like
@@ -13,7 +14,8 @@ import { chartFrame, finite, formatNumber, onWidth, round, svg } from '../chart/
13
14
  * crossfades their colours. Pointing at a day (or the arrows: up and down a
14
15
  * day, left and right a week, PageUp/PageDown four) lifts it with a ring and
15
16
  * the tooltip says its value and date; the "less … more" scale lights the
16
- * days of a step. Narrow screens scroll the weeks, ending on the latest.
17
+ * days of a step. Narrow screens scroll the weeks, ending on the latest, under
18
+ * the kit's scroll-area bar (its own lane below the last row).
17
19
  */
18
20
 
19
21
  export interface HeatmapDatum {
@@ -70,6 +72,9 @@ export function createHeatmapChart(root: HTMLElement | null, initial: HeatmapCha
70
72
  plot.prepend(scroller);
71
73
  scroller.append(canvas);
72
74
  scope.add(() => scroller.remove());
75
+ // The arrows already move through the days: the bar is not a tab stop.
76
+ const area = createScrollArea(plot, { viewport: scroller, axis: 'x', focusable: false, decorative: true });
77
+ scope.add(() => area.destroy());
73
78
 
74
79
  const scale = document.createElement('div');
75
80
  scale.dataset.mkHeatmapScale = '';
@@ -13,7 +13,11 @@ mk-heatmap-chart {
13
13
  overflow-x: auto;
14
14
  overflow-y: hidden;
15
15
  padding-block: 4px;
16
- scrollbar-width: thin;
16
+ }
17
+
18
+ /* Weeks that scroll leave the scroll-area bar a lane below the last row, so a click on a day never lands on it. */
19
+ [data-mk-overflow-x] > [data-mk-heatmap-scroll] {
20
+ padding-bottom: 16px;
17
21
  }
18
22
 
19
23
  [data-mk-chart-type='heatmap'] [data-mk-chart-svg] {
@@ -14,6 +14,7 @@ export const ICON_PATHS = {
14
14
  mic: 'M12 3a3 3 0 00-3 3v6a3 3 0 006 0V6a3 3 0 00-3-3zM5.5 11a6.5 6.5 0 0013 0M12 17.5V21',
15
15
  sparkle: 'M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z',
16
16
  grip: 'M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01',
17
+ toTop: 'M12 20V9M6.5 14.5L12 9l5.5 5.5M6 4.5h12',
17
18
  } as const;
18
19
 
19
20
  export type IconName = keyof typeof ICON_PATHS;
@@ -0,0 +1,26 @@
1
+ # Magnetize button
2
+
3
+ Partículas discretas reposan alrededor de un botón tonal. Hover o foco las atrae hacia el perímetro; al salir se separan y vuelven a reposar. Es un efecto de atracción de partículas, con el botón estable. Las transiciones usan `EASE.swatch` y se pueden interrumpir. No usa RAF ni un ticker propio en reposo.
4
+
5
+ ```ts
6
+ import { createMagnetizeButton } from '@yewdiel21/motion-kit';
7
+ import '@yewdiel21/motion-kit/css/magnetize-button';
8
+ const magnet = createMagnetizeButton(document.querySelector('#gather'), {
9
+ onAction: () => groupSelectedIdeas(),
10
+ });
11
+ // magnet.attract(); magnet.release(); magnet.destroy();
12
+ ```
13
+
14
+ `MagnetizeButtonOptions`: `label` (Reunir ideas, solo si falta contenido), `count` (14, limitado a 4–24), `reach` (42px, limitado a 16–90), `onAction`. `MagnetizeButtonInstance`: `attract()`, `release()`, `attracted`, `destroy()`. Emite `mk:action` en clic nativo, incluido teclado y tacto. El factory acepta un botón, un host con botón o crea uno si falta. Su `type` predeterminado es `button`.
15
+
16
+ ```tsx
17
+ <MagnetizeButton label="Reunir ideas" onAction={groupSelectedIdeas} instanceRef={magnetRef} />
18
+ ```
19
+
20
+ `MagnetizeButtonProps` añade atributos nativos e `instanceRef`. El motor posee los hijos; se usa `label` y se filtran `children`/`dangerouslySetInnerHTML` en ejecución.
21
+
22
+ ```html
23
+ <mk-magnetize-button count="14" reach="42"><button type="button">Reunir ideas</button></mk-magnetize-button>
24
+ ```
25
+
26
+ `defineMagnetizeButton()` registra `MkMagnetizeButtonElement`; atributos `label`, `count`, `reach`. Teclado muestra la misma atracción y un anillo de foco. Movimiento reducido o disabled oculta partículas; la acción sigue siendo nativa. Reserva hasta `reach` de aire alrededor del botón para ver el efecto. `destroy()` desconecta observers, media listeners, eventos y animaciones, retira partículas y restaura nodos y estilos originales.
@@ -0,0 +1,13 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createMagnetizeButton, type MagnetizeButtonInstance } from './engine';
3
+
4
+ export class MkMagnetizeButtonElement extends MkElement<MagnetizeButtonInstance> {
5
+ static options: Record<string, OptionType> = { label: 'string', count: 'number', reach: 'number' };
6
+ protected create(options: Record<string, unknown>) { return createMagnetizeButton(this, options); }
7
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
8
+ const focused = this.contains(document.activeElement);
9
+ super.attributeChangedCallback(name, previous, next);
10
+ if (focused) this.querySelector<HTMLButtonElement>('button')?.focus({ preventScroll: true });
11
+ }
12
+ }
13
+ export const defineMagnetizeButton = () => defineElement('mk-magnetize-button', MkMagnetizeButtonElement);
@@ -0,0 +1,84 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { inert, type Instance } from '../../core/types';
5
+ import { press } from '../../motion/micro';
6
+
7
+ export interface MagnetizeButtonOptions {
8
+ label?: string;
9
+ /** Discrete particles, clamped to 4–24. No idle ticker. */
10
+ count?: number;
11
+ /** Distance outside the button where the particles rest, default 42px. */
12
+ reach?: number;
13
+ onAction?: () => void;
14
+ }
15
+ export interface MagnetizeButtonInstance extends Instance { attract(): void; release(): void; readonly attracted: boolean; }
16
+
17
+ export function createMagnetizeButton(root: HTMLElement | null, options: MagnetizeButtonOptions = {}): MagnetizeButtonInstance {
18
+ if (!root) return inert({ attract() {}, release() {}, attracted: false });
19
+ registerMotion();
20
+ const scope = createScope();
21
+ let button = root.matches('button') ? root as HTMLButtonElement : root.querySelector<HTMLButtonElement>('button');
22
+ if (!button) { button = document.createElement('button'); root.append(button); const made = button; scope.add(() => made.remove()); }
23
+ const control = button;
24
+ if (!control.textContent?.trim() && !control.querySelector('[aria-label], svg, img')) {
25
+ const label = document.createElement('span'); label.textContent = options.label ?? 'Reunir ideas'; control.append(label); scope.add(() => label.remove());
26
+ }
27
+ const originalStyle = control.getAttribute('style');
28
+ const originalTransform = control.style.transform;
29
+ scope.add(() => { if (originalStyle === null) control.removeAttribute('style'); else control.setAttribute('style', originalStyle); });
30
+ scope.attr(control, 'type', control.getAttribute('type') ?? 'button');
31
+ scope.attr(control, 'data-mk-magnetize-button', '');
32
+ scope.attr(root, 'data-mk-ready', '');
33
+ const layer = document.createElement('span'); layer.dataset.mkMagnetParticles = ''; layer.setAttribute('aria-hidden', 'true'); control.append(layer); scope.add(() => layer.remove());
34
+ const count = Number.isFinite(options.count) ? Math.min(24, Math.max(4, Math.round(options.count!))) : 14;
35
+ const reach = Number.isFinite(options.reach) ? Math.min(90, Math.max(16, options.reach!)) : 42;
36
+ const dots: HTMLElement[] = [];
37
+ for (let i = 0; i < count; i++) { const dot = document.createElement('i'); layer.append(dot); dots.push(dot); }
38
+ let hovered = false;
39
+ let focused = false;
40
+ let attracted = false;
41
+ let tween: gsap.core.Timeline | null = null;
42
+ let pressing: gsap.core.Animation | null = null;
43
+ const disabled = () => control.disabled || control.getAttribute('aria-disabled') === 'true';
44
+ const positions = (near: boolean) => {
45
+ const w = control.offsetWidth || 160;
46
+ const h = control.offsetHeight || 48;
47
+ return dots.map((_, i) => {
48
+ const angle = i / count * Math.PI * 2 - Math.PI / 2;
49
+ // Intersect a ray with the rounded rectangle envelope; attraction lands at the perimeter.
50
+ const c = Math.cos(angle); const s = Math.sin(angle);
51
+ const boundary = Math.min((w / 2 + 4) / Math.max(.001, Math.abs(c)), (h / 2 + 4) / Math.max(.001, Math.abs(s)));
52
+ const radius = boundary + (near ? 0 : reach * (.72 + (i % 3) * .14));
53
+ return { x: c * radius, y: s * radius, opacity: near ? .88 : .25, scale: near ? 1 : .6 };
54
+ });
55
+ };
56
+ const paint = (near: boolean, animate: boolean) => {
57
+ tween?.kill(); tween = null;
58
+ layer.hidden = reduced() || disabled();
59
+ if (layer.hidden) { attracted = false; scope.attr(control, 'data-mk-attracted', null); return; }
60
+ attracted = near;
61
+ scope.attr(control, 'data-mk-attracted', near ? '' : null);
62
+ const targets = positions(near);
63
+ if (!animate) { dots.forEach((dot, i) => gsap.set(dot, targets[i]!)); return; }
64
+ tween = gsap.timeline();
65
+ dots.forEach((dot, i) => tween!.to(dot, { ...targets[i]!, duration: near ? .62 : .48, ease: near ? EASE.swatch : EASE.signature }, i * .008));
66
+ };
67
+ const sync = () => paint(hovered || focused, true);
68
+ scope.on(control, 'pointerenter', () => { hovered = true; sync(); });
69
+ scope.on(control, 'pointerleave', () => { hovered = false; sync(); });
70
+ scope.on(control, 'focus', () => { focused = true; sync(); });
71
+ scope.on(control, 'blur', () => { focused = false; sync(); });
72
+ scope.on(control, 'click', (event) => {
73
+ if (disabled()) { event.preventDefault(); return; }
74
+ pressing?.kill(); pressing = press(control);
75
+ pressing?.eventCallback('onComplete', () => { gsap.set(control, { clearProps: 'transform,scale' }); control.style.transform = originalTransform; if (!control.getAttribute('style')) control.removeAttribute('style'); });
76
+ options.onAction?.(); root.dispatchEvent(new CustomEvent('mk:action', { bubbles: true }));
77
+ });
78
+ if (typeof ResizeObserver !== 'undefined') scope.observe(new ResizeObserver(() => paint(attracted, false))).observe(control);
79
+ if (typeof MutationObserver !== 'undefined') scope.observe(new MutationObserver(() => paint(hovered || focused, false))).observe(control, { attributes: true, attributeFilter: ['disabled', 'aria-disabled'] });
80
+ if (typeof matchMedia === 'function') { const media = matchMedia('(prefers-reduced-motion: reduce)'); scope.on(media, 'change', () => paint(hovered || focused, false)); }
81
+ paint(false, false);
82
+ scope.add(() => { tween?.kill(); pressing?.kill(); gsap.killTweensOf(dots); });
83
+ return { attract: () => { if (!scope.disposed) paint(true, true); }, release: () => { if (!scope.disposed) paint(false, true); }, get attracted() { return attracted; }, destroy: () => scope.dispose() };
84
+ }
@@ -0,0 +1,17 @@
1
+ .mk-magnetize-button,
2
+ mk-magnetize-button { display: inline-flex; overflow: visible; }
3
+ [data-mk-magnetize-button] {
4
+ position: relative; display: inline-flex; align-items: center; justify-content: center; gap: .6rem; min-height: 48px;
5
+ padding: .8rem 1.5rem; border: 1px solid var(--mk-outline-soft); border-radius: 16px; background: var(--mk-surface-3);
6
+ color: var(--mk-text); font: 500 .9375rem var(--mk-font); cursor: pointer; overflow: visible;
7
+ transition: background 160ms, border-radius 350ms var(--mk-ease-swatch);
8
+ }
9
+ [data-mk-magnetize-button]:is(:hover, [data-mk-attracted]):not(:disabled) { border-radius: 24px; background: var(--mk-surface-4); }
10
+ [data-mk-magnetize-button]:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 8px; }
11
+ [data-mk-magnetize-button]:disabled,
12
+ [data-mk-magnetize-button][aria-disabled='true'] { opacity: .4; cursor: not-allowed; }
13
+ [data-mk-magnet-particles] { position: absolute; inset: 0; pointer-events: none; }
14
+ [data-mk-magnet-particles][hidden] { display: none; }
15
+ [data-mk-magnet-particles] i { position: absolute; top: 50%; left: 50%; width: 4px; height: 4px; margin: -2px; border-radius: 999px; background: var(--mk-muted); }
16
+ [data-mk-magnet-particles] i:nth-child(4n) { width: 5px; height: 5px; background: var(--mk-accent); }
17
+ @media (prefers-reduced-motion: reduce) { [data-mk-magnet-particles] { display: none; } [data-mk-magnetize-button] { transition: none; } }
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+ import { useRef, type ButtonHTMLAttributes, type Ref } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createMagnetizeButton, type MagnetizeButtonInstance, type MagnetizeButtonOptions } from './engine';
5
+
6
+ export interface MagnetizeButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children' | 'dangerouslySetInnerHTML'>, MagnetizeButtonOptions { instanceRef?: Ref<MagnetizeButtonInstance>; }
7
+ export function MagnetizeButton({ label = 'Reunir ideas', count, reach, onAction, instanceRef, type = 'button', className, ...props }: MagnetizeButtonProps) {
8
+ const { children: _children, dangerouslySetInnerHTML: _html, ...safe } = props as ButtonHTMLAttributes<HTMLButtonElement>;
9
+ const root = useRef<HTMLButtonElement>(null);
10
+ const instance = useRef<MagnetizeButtonInstance | null>(null);
11
+ const action = useLatest(onAction);
12
+ useIsomorphicLayoutEffect(() => {
13
+ const created = createMagnetizeButton(root.current, { label, count, reach, onAction: () => action.current?.() });
14
+ instance.current = created;
15
+ return () => { created.destroy(); instance.current = null; };
16
+ }, [label, count, reach]);
17
+ useIsomorphicLayoutEffect(() => {
18
+ if (typeof instanceRef === 'function') instanceRef(instance.current); else if (instanceRef) instanceRef.current = instance.current;
19
+ return () => { if (typeof instanceRef === 'function') instanceRef(null); else if (instanceRef) instanceRef.current = null; };
20
+ }, [instanceRef, label, count, reach]);
21
+ return <button {...safe} type={type} ref={root} className={['mk-magnetize-button', className].filter(Boolean).join(' ')} />;
22
+ }
@@ -0,0 +1,28 @@
1
+ # Morphing page dots
2
+
3
+ Indicador de páginas: una píldora se estira hacia el nuevo índice y su cola alcanza el destino con `EASE.swatch`. Los puntos siguen siendo botones nativos de 44px con foco visible y `aria-current="page"`. No inicia reproducción automática ni crea diapositivas.
4
+
5
+ ```ts
6
+ import { createMorphingPageDots } from '@yewdiel21/motion-kit';
7
+ import '@yewdiel21/motion-kit/css/morphing-page-dots';
8
+ const dots = createMorphingPageDots(document.querySelector('#pages'), {
9
+ count: 4, value: 0, pageIds: ['slide-a', 'slide-b', 'slide-c', 'slide-d'],
10
+ onChange: (index) => showSlide(index),
11
+ });
12
+ dots.setValue(2, true);
13
+ // dots.destroy();
14
+ ```
15
+
16
+ `MorphingPageDotsOptions`: `count` (5, entre 1–20), `value` (índice desde 0), `label` (Páginas), `labels` (nombres de cada página), `pageIds` (aria-controls opcional), `disabled`, `onChange(index)`. `MorphingPageDotsInstance`: `setValue(index, notify = false)`, `value`, `destroy()`. Emite `mk:change` con `{value}` al notificar. Flechas izquierda/derecha recorren y seleccionan con wrapping; Home/End van a los extremos. Respeta RTL. La app debe mantener las diapositivas y su contenido accesible sincronizados.
17
+
18
+ ```tsx
19
+ <MorphingPageDots count={slides.length} value={index} onChange={setIndex} />
20
+ ```
21
+
22
+ React admite `value` controlado o `defaultValue`, `instanceRef` y atributos de div. El estado controlado sigue siendo la autoridad si se rechaza el cambio. El motor posee todos los hijos; `children` y `dangerouslySetInnerHTML` se filtran también en ejecución.
23
+
24
+ ```html
25
+ <mk-morphing-page-dots count="4" value="0" labels='["Sierra","Lago","Pinar","Dunas"]'></mk-morphing-page-dots>
26
+ ```
27
+
28
+ `defineMorphingPageDots()` registra `MkMorphingPageDotsElement`; atributos `count`, `value`, `label`, `labels` (JSON), `page-ids` (JSON), `disabled`. Conserva la selección al cambiar presentación o reconectar; un cambio del atributo `value` la reemplaza. Movimiento reducido coloca la píldora directamente. `destroy()` elimina observers, animaciones, eventos y devuelve los nodos/atributos originales.
@@ -0,0 +1,16 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createMorphingPageDots, type MorphingPageDotsInstance } from './engine';
3
+
4
+ export class MkMorphingPageDotsElement extends MkElement<MorphingPageDotsInstance> {
5
+ static options: Record<string, OptionType> = { count: 'number', value: 'number', label: 'string', labels: 'json', pageIds: 'json', disabled: 'boolean' };
6
+ private retained: number | undefined;
7
+ protected create(options: Record<string, unknown>) { return createMorphingPageDots(this, { ...options, ...(this.retained === undefined ? {} : { value: this.retained }) }); }
8
+ disconnectedCallback() { this.retained = this.instance?.value ?? this.retained; super.disconnectedCallback(); }
9
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
10
+ const focusIndex = [...this.querySelectorAll('button')].findIndex((button) => button === document.activeElement);
11
+ if (previous !== next) this.retained = name === 'value' ? undefined : this.instance?.value ?? this.retained;
12
+ super.attributeChangedCallback(name, previous, next);
13
+ if (focusIndex >= 0) { const buttons = this.querySelectorAll('button'); buttons[Math.min(focusIndex, buttons.length - 1)]?.focus({ preventScroll: true }); }
14
+ }
15
+ }
16
+ export const defineMorphingPageDots = () => defineElement('mk-morphing-page-dots', MkMorphingPageDotsElement);