@yewdiel21/motion-kit 0.15.0 → 0.17.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 (85) hide show
  1. package/README.md +25 -0
  2. package/dist/auto/index.d.ts +8 -3
  3. package/dist/auto/index.js +13 -3
  4. package/dist/auto/index.js.map +1 -1
  5. package/dist/{chunk-A6QQCF6V.js → chunk-7IQTMF7Y.js} +1542 -54
  6. package/dist/chunk-7IQTMF7Y.js.map +1 -0
  7. package/dist/{chunk-6IRJYXJU.js → chunk-BZLMCHUD.js} +2 -2
  8. package/dist/{chunk-JXRS3OEA.js → chunk-VJFPTLAK.js} +29 -7
  9. package/dist/chunk-VJFPTLAK.js.map +1 -0
  10. package/dist/css/components/chart.css +7 -0
  11. package/dist/css/components/drilldown-menu.css +130 -0
  12. package/dist/css/components/heatmap-chart.css +7 -0
  13. package/dist/css/components/pie-chart.css +7 -0
  14. package/dist/css/components/radar-chart.css +7 -0
  15. package/dist/css/components/radial-chart.css +16 -2
  16. package/dist/css/components/sankey-chart.css +7 -0
  17. package/dist/css/components/scroll-island.css +3 -0
  18. package/dist/css/components/signature-pad.css +104 -0
  19. package/dist/css/components/voice-message-bubble.css +50 -0
  20. package/dist/css/components/waveform-player.css +36 -0
  21. package/dist/css/components/wheel-picker.css +17 -0
  22. package/dist/elements/index.d.ts +90 -4
  23. package/dist/elements/index.js +290 -8
  24. package/dist/elements/index.js.map +1 -1
  25. package/dist/{reveal-BfyKKsw4.d.ts → engine-Bn6hC87X.d.ts} +206 -1
  26. package/dist/index.css +324 -2
  27. package/dist/index.d.ts +3 -3
  28. package/dist/index.js +13 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/react/index.d.ts +48 -10
  31. package/dist/react/index.js +240 -10
  32. package/dist/react/index.js.map +1 -1
  33. package/dist/{reduced-6X_bns60.d.ts → reduced-YS6TZjpa.d.ts} +1 -1
  34. package/dist/{store-CA2Qcun_.d.ts → store-CZ6vdtIE.d.ts} +5 -1
  35. package/package.json +110 -110
  36. package/registry.json +64 -0
  37. package/src/auto/auto-init.ts +308 -293
  38. package/src/components/add-to-cart/README.md +49 -34
  39. package/src/components/add-to-cart/react.tsx +4 -0
  40. package/src/components/chart/README.md +49 -34
  41. package/src/components/chart/chart.css +7 -0
  42. package/src/components/chart/element.ts +2 -1
  43. package/src/components/chart/engine.ts +105 -13
  44. package/src/components/chart/react.tsx +2 -2
  45. package/src/components/drilldown-menu/README.md +163 -0
  46. package/src/components/drilldown-menu/drilldown-menu.css +129 -0
  47. package/src/components/drilldown-menu/element.ts +59 -0
  48. package/src/components/drilldown-menu/engine.ts +338 -0
  49. package/src/components/drilldown-menu/react.tsx +76 -0
  50. package/src/components/radial-chart/README.md +10 -0
  51. package/src/components/radial-chart/element.ts +2 -2
  52. package/src/components/radial-chart/engine.ts +33 -6
  53. package/src/components/radial-chart/radial-chart.css +9 -2
  54. package/src/components/radial-chart/react.tsx +2 -2
  55. package/src/components/scroll-island/README.md +44 -34
  56. package/src/components/scroll-island/element.ts +1 -1
  57. package/src/components/scroll-island/engine.ts +25 -3
  58. package/src/components/scroll-island/react.tsx +3 -3
  59. package/src/components/scroll-island/scroll-island.css +3 -0
  60. package/src/components/signature-pad/README.md +97 -0
  61. package/src/components/signature-pad/element.ts +57 -0
  62. package/src/components/signature-pad/engine.ts +437 -0
  63. package/src/components/signature-pad/react.tsx +56 -0
  64. package/src/components/signature-pad/signature-pad.css +103 -0
  65. package/src/components/voice-message-bubble/README.md +31 -0
  66. package/src/components/voice-message-bubble/element.ts +20 -0
  67. package/src/components/voice-message-bubble/engine.ts +68 -0
  68. package/src/components/voice-message-bubble/react.tsx +27 -0
  69. package/src/components/voice-message-bubble/voice-message-bubble.css +12 -0
  70. package/src/components/waveform-player/README.md +30 -0
  71. package/src/components/waveform-player/element.ts +21 -0
  72. package/src/components/waveform-player/engine.ts +261 -0
  73. package/src/components/waveform-player/react.tsx +27 -0
  74. package/src/components/waveform-player/waveform-player.css +35 -0
  75. package/src/components/wheel-picker/README.md +33 -0
  76. package/src/components/wheel-picker/element.ts +18 -0
  77. package/src/components/wheel-picker/engine.ts +210 -0
  78. package/src/components/wheel-picker/react.tsx +32 -0
  79. package/src/components/wheel-picker/wheel-picker.css +16 -0
  80. package/src/elements/index.ts +273 -258
  81. package/src/index.ts +295 -290
  82. package/src/react/index.ts +176 -171
  83. package/dist/chunk-A6QQCF6V.js.map +0 -1
  84. package/dist/chunk-JXRS3OEA.js.map +0 -1
  85. /package/dist/{chunk-6IRJYXJU.js.map → chunk-BZLMCHUD.js.map} +0 -0
@@ -3,7 +3,7 @@ import { reduced } from '../../core/reduced';
3
3
  import { createScope } from '../../core/scope';
4
4
  import { inert, type Instance } from '../../core/types';
5
5
  import { blurIn, rollDigits } from '../../motion/micro';
6
- import { chartFrame, finite, formatNumber, onWidth, polar, round, slotColor, svg } from '../chart/parts';
6
+ import { arcPath, chartFrame, finite, formatNumber, onWidth, polar, round, slotColor, svg } from '../chart/parts';
7
7
 
8
8
  /*
9
9
  * Radial chart: totals as rings, one inside the other, each filling its track
@@ -33,6 +33,10 @@ export interface RadialChartOptions {
33
33
  size?: number;
34
34
  /** Ring thickness in px (default: what fits, up to 16). */
35
35
  thickness?: number;
36
+ /** Discrete sectors per ring (0: continuous, default). */
37
+ segments?: number;
38
+ /** Show values (default) or progress toward the goals in the middle. */
39
+ center?: 'value' | 'percent';
36
40
  label?: string;
37
41
  /** The middle when nothing is chosen (default 'Total'). */
38
42
  totalLabel?: string;
@@ -90,6 +94,7 @@ export function createRadialChart(root: HTMLElement | null, initial: RadialChart
90
94
  const percent = (ring: { value: number; max: number }) => new Intl.NumberFormat(undefined, { style: 'percent', maximumFractionDigits: 0 }).format(ring.value / ring.max);
91
95
  const sweep = () => (Math.min(360, Math.max(90, finite(options.sweep) ? options.sweep : 360)) * Math.PI) / 180;
92
96
  const startAngle = () => -sweep() / 2 + (sweep() >= Math.PI * 2 - 1e-6 ? Math.PI : 0);
97
+ const segments = () => finite(options.segments) ? Math.max(0, Math.min(80, Math.round(options.segments))) : 0;
93
98
 
94
99
  let geometry = { size: 240, cx: 120, cy: 120, radii: [] as number[], thickness: 12 };
95
100
  let arcs: SVGPathElement[] = [];
@@ -109,17 +114,35 @@ export function createRadialChart(root: HTMLElement | null, initial: RadialChart
109
114
  const thickness = finite(options.thickness) ? options.thickness : Math.max(4, Math.min(16, fit));
110
115
  const spacing = thickness * 1.35;
111
116
  geometry = { size, cx: size / 2, cy: size / 2, thickness, radii: model.map((_, i) => outer - thickness / 2 - i * spacing) };
112
- canvas.setAttribute('viewBox', `0 0 ${size} ${size}`);
117
+ const half = sweep() <= Math.PI + 1e-6;
118
+ const height = half ? size / 2 + 54 : size;
119
+ canvas.setAttribute('viewBox', `0 0 ${size} ${height}`);
113
120
  canvas.setAttribute('width', String(size));
114
- canvas.setAttribute('height', String(size));
121
+ canvas.setAttribute('height', String(height));
115
122
  plot.style.setProperty('--mk-radial-size', `${size}px`);
116
123
  plot.style.setProperty('--mk-radial-thickness', `${thickness}px`);
124
+ plot.style.setProperty('--mk-radial-center-y', half ? `${size / 2}px` : '50%');
125
+ plot.style.setProperty('--mk-radial-center-width', n === 1 ? '0.68' : '0.36');
117
126
  };
118
127
  scope.add(() => {
119
128
  plot.style.removeProperty('--mk-radial-size');
120
129
  plot.style.removeProperty('--mk-radial-thickness');
130
+ plot.style.removeProperty('--mk-radial-center-y');
131
+ plot.style.removeProperty('--mk-radial-center-width');
121
132
  });
122
133
 
134
+ const segmentedPath = (radius: number, share: number) => {
135
+ const count = segments(), step = sweep() / count, a0 = startAngle();
136
+ const gap = Math.min(step * 0.3, 4 / Math.max(1, radius));
137
+ const limit = a0 + sweep() * Math.min(1, Math.max(0, share));
138
+ const outer = radius + geometry.thickness / 2 - 1, inner = Math.max(0, radius - geometry.thickness / 2 + 1);
139
+ return Array.from({ length: count }, (_, index) => {
140
+ const from = a0 + index * step + gap / 2;
141
+ const to = Math.min(a0 + (index + 1) * step - gap / 2, limit);
142
+ return to > from ? arcPath(geometry.cx, geometry.cy, outer, inner, from, to) : '';
143
+ }).join('');
144
+ };
145
+
123
146
  const build = () => {
124
147
  canvas.replaceChildren();
125
148
  const { cx, cy, radii } = geometry;
@@ -127,13 +150,14 @@ export function createRadialChart(root: HTMLElement | null, initial: RadialChart
127
150
  const back = svg('g', { 'data-mk-part': 'tracks' });
128
151
  const front = svg('g', { 'data-mk-part': 'series' });
129
152
  tracks = model.map((_, i) => {
130
- const track = svg('path', { d: strokeArc(cx, cy, radii[i]!, a0, a0 + sweep()), 'data-mk-index': i });
153
+ const track = svg('path', { d: segments() ? segmentedPath(radii[i]!, 1) : strokeArc(cx, cy, radii[i]!, a0, a0 + sweep()), 'data-mk-index': i });
131
154
  back.append(track);
132
155
  return track;
133
156
  });
134
157
  arcs = model.map((ring, i) => {
135
158
  const arc = svg('path', { 'data-mk-index': i, 'data-mk-part': 'ring' });
136
159
  arc.style.stroke = ring.color;
160
+ if (segments()) arc.style.fill = ring.color;
137
161
  front.append(arc);
138
162
  return arc;
139
163
  });
@@ -146,14 +170,16 @@ export function createRadialChart(root: HTMLElement | null, initial: RadialChart
146
170
  const a0 = startAngle();
147
171
  arcs.forEach((arc, i) => {
148
172
  const share = Math.min(1, Math.max(0, (values[i] ?? 0) / model[i]!.max));
149
- arc.setAttribute('d', share > 0.001 ? strokeArc(cx, cy, radii[i]!, a0, a0 + sweep() * share) : '');
173
+ arc.setAttribute('d', share > 0.001 ? segments() ? segmentedPath(radii[i]!, share) : strokeArc(cx, cy, radii[i]!, a0, a0 + sweep() * share) : '');
150
174
  });
151
175
  };
152
176
 
153
177
  const paintCenter = (animate: boolean) => {
154
178
  center.hidden = !model.length;
155
179
  const ring = selected === null ? null : model[selected];
156
- const text = ring ? format(ring.value) : format(total());
180
+ const denominator = ring ? ring.max : model.reduce((sum, each) => sum + each.max, 0);
181
+ const value = ring ? ring.value : total();
182
+ const text = options.center === 'percent' ? new Intl.NumberFormat(undefined, { style: 'percent', maximumFractionDigits: 1 }).format(value / (denominator || 1)) : format(value);
157
183
  const caption = ring ? `${ring.label} · ${percent(ring)}` : (options.totalLabel ?? 'Total');
158
184
  const changed = centerLabel.textContent !== caption;
159
185
  if (centerValue.textContent !== text) {
@@ -178,6 +204,7 @@ export function createRadialChart(root: HTMLElement | null, initial: RadialChart
178
204
 
179
205
  const draw = (animate: boolean) => {
180
206
  glide?.kill();
207
+ scope.attr(root, 'data-mk-segmented', segments() ? '' : null);
181
208
  const name = options.label ?? 'Gráfica radial';
182
209
  canvas.setAttribute('aria-label', model.length ? `${name}. ${model.length} anillos, total ${format(total())}. Usa las flechas para recorrerlos.` : `${name}: ${options.emptyLabel ?? 'sin datos'}`);
183
210
  frame.fillTable(name, ['Anillo', 'Valor', 'Meta', 'Avance'], model.map((ring) => [ring.label, format(ring.value), format(ring.max), percent(ring)]));
@@ -32,6 +32,13 @@ mk-radial-chart {
32
32
  stroke: var(--mk-chart-grid);
33
33
  }
34
34
 
35
+ [data-mk-chart-type='radial'][data-mk-segmented] :is([data-mk-part='tracks'], [data-mk-part='series']) path {
36
+ stroke-width: 1px;
37
+ stroke-linejoin: round;
38
+ }
39
+ [data-mk-chart-type='radial'][data-mk-segmented] [data-mk-part='tracks'] path { fill: var(--mk-chart-track); stroke: var(--mk-chart-track); }
40
+ [data-mk-chart-type='radial'][data-mk-segmented] [data-mk-part='series'] path[data-mk-on] { filter: brightness(1.12); stroke-width: 2px; }
41
+
35
42
  [data-mk-chart-type='radial'] :is([data-mk-part='tracks'], [data-mk-part='series']) path[data-mk-on] {
36
43
  stroke-width: calc(var(--mk-radial-thickness, 12px) + 4px);
37
44
  }
@@ -42,12 +49,12 @@ mk-radial-chart {
42
49
 
43
50
  [data-mk-radial-center] {
44
51
  position: absolute;
45
- top: 50%;
52
+ top: var(--mk-radial-center-y, 50%);
46
53
  left: 50%;
47
54
  display: grid;
48
55
  justify-items: center;
49
56
  gap: 0.15rem;
50
- max-width: calc(var(--mk-radial-size, 240px) * 0.36);
57
+ max-width: calc(var(--mk-radial-size, 240px) * var(--mk-radial-center-width, 0.36));
51
58
  transform: translate(-50%, -50%);
52
59
  text-align: center;
53
60
  pointer-events: none;
@@ -8,7 +8,7 @@ export interface RadialChartProps extends Omit<HTMLAttributes<HTMLDivElement>, '
8
8
  }
9
9
 
10
10
  /** <RadialChart label="Metas" data={[{ label: 'Pasos', value: 7400, max: 10000 }, …]} /> */
11
- export function RadialChart({ data, max, sweep, size, thickness, label, totalLabel, legend, emptyLabel, formatValue, onSelect, instanceRef, className, ...props }: RadialChartProps) {
12
- const ref = useChart(createRadialChart, { data, max, sweep, size, thickness, label, totalLabel, legend, emptyLabel } as RadialChartOptions, { formatValue, onSelect }, instanceRef);
11
+ export function RadialChart({ data, max, sweep, size, thickness, segments, center, label, totalLabel, legend, emptyLabel, formatValue, onSelect, instanceRef, className, ...props }: RadialChartProps) {
12
+ const ref = useChart(createRadialChart, { data, max, sweep, size, thickness, segments, center, label, totalLabel, legend, emptyLabel } as RadialChartOptions, { formatValue, onSelect }, instanceRef);
13
13
  return <div {...props} ref={ref} className={['mk-chart mk-radial-chart', className].filter(Boolean).join(' ')} />;
14
14
  }
@@ -1,34 +1,44 @@
1
- # scroll-island
2
-
3
- Qué hace, en una frase.
4
-
5
- Origen: archivo del kit y parámetros (tiempos, curvas).
6
-
7
- ## HTML / Astro
8
-
9
- ```html
10
- <mk-scroll-island></mk-scroll-island>
11
- <script>
12
- import { defineElements } from '@yewdiel21/motion-kit/elements';
13
- defineElements(['mk-scroll-island']);
14
- </script>
15
- ```
16
-
17
- ## React
18
-
19
- ```tsx
20
- <ScrollIsland />
21
- ```
22
-
23
- ## Opciones
24
-
25
- | Opción | Atributo | Por defecto | Qué hace |
26
- |---|---|---|---|
27
-
28
- ## Variables CSS
29
-
30
- `--mk-scroll-island-radius`
31
-
32
- ## Accesibilidad
33
-
34
- Roles, foco y teclado. Con movimiento reducido…
1
+ # scroll-island
2
+
3
+ Una barra fina que se transforma en una píldora con el porcentaje y la sección actual al acercar el puntero al borde derecho. Arrastra la píldora o pulsa su carril para desplazarte.
4
+
5
+ ## React
6
+
7
+ ```tsx
8
+ import '@yewdiel21/motion-kit/css';
9
+ import { ScrollIsland } from '@yewdiel21/motion-kit/react';
10
+
11
+ <ScrollIsland persistent style={{ height: 360, overflow: 'auto' }}
12
+ tabIndex={0} role="region" aria-label="Guía de diseño">
13
+ <section style={{ minHeight: 400 }}><h3>Introducción</h3>Contenido…</section>
14
+ <section style={{ minHeight: 400 }}><h3>Detalles</h3>Contenido…</section>
15
+ </ScrollIsland>
16
+ ```
17
+
18
+ **El contenedor debe tener altura limitada, overflow y contenido suficiente para desplazarse.** Si todo cabe, la barra se oculta. `persistent` deja visible la barra fina mientras está en reposo; por defecto aparece al desplazarse y desaparece después.
19
+
20
+ ## HTML / Astro
21
+
22
+ ```html
23
+ <mk-scroll-island persistent style="display:block;height:360px;overflow:auto"
24
+ tabindex="0" role="region" aria-label="Guía">
25
+ <section style="min-height:400px"><h3>Introducción</h3>Contenido…</section>
26
+ <section style="min-height:400px"><h3>Detalles</h3>Contenido…</section>
27
+ </mk-scroll-island>
28
+ <script>
29
+ import { defineElements } from '@yewdiel21/motion-kit/elements';
30
+ defineElements(['mk-scroll-island']);
31
+ </script>
32
+ ```
33
+
34
+ ## Opciones
35
+
36
+ - `page`: sigue el scroll de la página en vez del contenedor. En React `<ScrollIsland page />` no dibuja contenedor propio.
37
+ - `sections`: selector de encabezados, `h2, h3` por defecto; `false` omite el nombre de sección en la factory/React.
38
+ - `reach`: distancia del borde que activa la píldora, 56 px.
39
+ - `idle`: demora para ocultarse, 900 ms.
40
+ - `persistent`: mantiene la barra fina visible, false por defecto.
41
+ - `onSection(section)`: cambio de sección, también `mk:section` con `detail.section`.
42
+ - Factory `createScrollIsland(root, options)`; instancia `refresh()` y `destroy()`.
43
+
44
+ El overlay decorativo usa `aria-hidden`; el contenido mantiene su desplazamiento nativo por teclado y tacto. Dale `tabindex="0"` y un nombre al panel. Respeta movimiento reducido, se adapta al tamaño del contenedor y elimina el overlay al desmontarse.
@@ -6,7 +6,7 @@ import { createScrollIsland, type ScrollIslandInstance } from './engine';
6
6
  * or <mk-scroll-island page></mk-scroll-island> for the page. Emits mk:section ({section}).
7
7
  */
8
8
  export class MkScrollIslandElement extends MkElement<ScrollIslandInstance> {
9
- static options: Record<string, OptionType> = { sections: 'string', reach: 'number', idle: 'number', page: 'boolean' };
9
+ static options: Record<string, OptionType> = { sections: 'string', reach: 'number', idle: 'number', persistent: 'boolean', page: 'boolean' };
10
10
 
11
11
  protected create(options: Record<string, unknown>) {
12
12
  return createScrollIsland(options.page ? document.documentElement : this, options);
@@ -23,6 +23,8 @@ export interface ScrollIslandOptions {
23
23
  reach?: number;
24
24
  /** ms the bar stays after scrolling stops (default 900). */
25
25
  idle?: number;
26
+ /** Keep the thin bar visible while idle (default false). */
27
+ persistent?: boolean;
26
28
  /** Follow the page's scroll (default: when the root is <html> or <body>). */
27
29
  page?: boolean;
28
30
  /** The current section changed (its heading's text, or null before the first). */
@@ -52,6 +54,7 @@ export function createScrollIsland(root: HTMLElement | null, options: ScrollIsla
52
54
  const marked = page ? document.documentElement : root;
53
55
  // The scroller's own bar hides (scroll-island.css).
54
56
  scope.attr(marked, 'data-mk-scroll-island-host', '');
57
+ scope.attr(root, 'data-mk-ready', '');
55
58
 
56
59
  const island = document.createElement('div');
57
60
  island.dataset.mkScrollIsland = '';
@@ -99,6 +102,16 @@ export function createScrollIsland(root: HTMLElement | null, options: ScrollIsla
99
102
  let section: string | null | undefined;
100
103
  let hideTimer: ReturnType<typeof setTimeout> | undefined;
101
104
  let dragging: { id: number; offset: number } | null = null;
105
+ let scrollBehavior: { value: string; priority: string; hadStyle: boolean } | null = null;
106
+ const restoreBehavior = () => {
107
+ if (!scrollBehavior) return;
108
+ const previous = scrollBehavior;
109
+ if (previous.value) scroller.style.setProperty('scroll-behavior', previous.value, previous.priority);
110
+ else scroller.style.removeProperty('scroll-behavior');
111
+ if (!previous.hadStyle && !scroller.getAttribute('style')) scroller.removeAttribute('style');
112
+ scrollBehavior = null;
113
+ };
114
+ scope.add(restoreBehavior);
102
115
  scope.add(() => clearTimeout(hideTimer));
103
116
 
104
117
  const headings = () => (options.sections === false ? [] : [...(page ? document.body : root).querySelectorAll<HTMLElement>(options.sections ?? 'h2, h3')]);
@@ -116,7 +129,7 @@ export function createScrollIsland(root: HTMLElement | null, options: ScrollIsla
116
129
  const size = as === 'island' ? ISLAND_HEIGHT : Math.max(MIN_BAR, Math.min(height, height * (scroller.clientHeight / Math.max(1, scroller.scrollHeight))));
117
130
  const top = (height - size) * progress();
118
131
  if (as !== 'island') return { top, height: size, width: BAR_WIDTH, borderRadius: BAR_WIDTH / 2 };
119
- return { top, height: size, width: Math.max(ISLAND_HEIGHT, content.scrollWidth + 28), borderRadius: size / 2 };
132
+ return { top, height: size, width: Math.min(Math.max(ISLAND_HEIGHT, track().right - track().left - INSET * 2), Math.max(ISLAND_HEIGHT, content.scrollWidth + 28)), borderRadius: size / 2 };
120
133
  };
121
134
 
122
135
  const place = () => {
@@ -158,7 +171,7 @@ export function createScrollIsland(root: HTMLElement | null, options: ScrollIsla
158
171
  const settleLater = () => {
159
172
  clearTimeout(hideTimer);
160
173
  hideTimer = setTimeout(() => {
161
- if (state === 'bar' && !dragging) go('hidden');
174
+ if (state === 'bar' && !dragging && !options.persistent) go('hidden');
162
175
  }, idle);
163
176
  };
164
177
 
@@ -213,6 +226,9 @@ export function createScrollIsland(root: HTMLElement | null, options: ScrollIsla
213
226
  if (state !== 'island' || down.button !== 0) return;
214
227
  down.preventDefault();
215
228
  down.stopPropagation();
229
+ if (!scrollBehavior) scrollBehavior = { value: scroller.style.getPropertyValue('scroll-behavior'), priority: scroller.style.getPropertyPriority('scroll-behavior'), hadStyle: scroller.hasAttribute('style') };
230
+ // A drag follows the pointer directly even when the app uses smooth scrolling.
231
+ scroller.style.setProperty('scroll-behavior', 'auto', 'important');
216
232
  const box = thumb.getBoundingClientRect();
217
233
  dragging = { id: down.pointerId, offset: down.clientY - box.top || ISLAND_HEIGHT / 2 };
218
234
  island.setAttribute('data-mk-dragging', '');
@@ -225,6 +241,7 @@ export function createScrollIsland(root: HTMLElement | null, options: ScrollIsla
225
241
  const release = (event: Event) => {
226
242
  if (!dragging || (event as PointerEvent).pointerId !== dragging.id) return;
227
243
  dragging = null;
244
+ restoreBehavior();
228
245
  island.removeAttribute('data-mk-dragging');
229
246
  };
230
247
  scope.on(window, 'pointerup', release);
@@ -240,7 +257,12 @@ export function createScrollIsland(root: HTMLElement | null, options: ScrollIsla
240
257
  });
241
258
 
242
259
  paintText();
243
- go('hidden');
260
+ go(options.persistent ? 'bar' : 'hidden');
261
+ if (typeof ResizeObserver === 'function') {
262
+ const observer = scope.observe(new ResizeObserver(() => { paintText(); go(state, true); }));
263
+ observer.observe(root);
264
+ for (const child of root.children) observer.observe(child);
265
+ }
244
266
 
245
267
  return {
246
268
  refresh: () => {
@@ -9,14 +9,14 @@ export interface ScrollIslandProps extends HTMLAttributes<HTMLDivElement>, Scrol
9
9
  * <ScrollIsland style={{ height: 400, overflow: 'auto' }}>…</ScrollIsland> scrolls its children with the island;
10
10
  * <ScrollIsland page /> puts it on the page (it renders nothing of its own then).
11
11
  */
12
- export function ScrollIsland({ sections, reach, idle, page, onSection, className, children, ...props }: ScrollIslandProps) {
12
+ export function ScrollIsland({ sections, reach, idle, persistent, page, onSection, className, children, ...props }: ScrollIslandProps) {
13
13
  const ref = useRef<HTMLDivElement>(null);
14
14
  const section = useLatest(onSection);
15
15
  useIsomorphicLayoutEffect(() => {
16
- const created = createScrollIsland(page ? document.documentElement : ref.current, { sections, reach, idle, page, onSection: (name) => section.current?.(name) });
16
+ const created = createScrollIsland(page ? document.documentElement : ref.current, { sections, reach, idle, persistent, page, onSection: (name) => section.current?.(name) });
17
17
  return () => created.destroy();
18
18
  // eslint-disable-next-line react-hooks/exhaustive-deps
19
- }, [sections, reach, idle, page]);
19
+ }, [sections, reach, idle, persistent, page]);
20
20
  if (page) return null;
21
21
  return (
22
22
  <div {...props} ref={ref} className={['mk-scroll-island', className].filter(Boolean).join(' ')}>
@@ -73,6 +73,8 @@ mk-scroll-island {
73
73
  [data-mk-island-content] {
74
74
  display: inline-flex;
75
75
  flex: none;
76
+ max-width: 100%;
77
+ min-width: 0;
76
78
  align-items: center;
77
79
  gap: 0.5rem;
78
80
  padding: 0 14px;
@@ -91,6 +93,7 @@ mk-scroll-island {
91
93
  }
92
94
 
93
95
  [data-mk-island-section] {
96
+ min-width: 0;
94
97
  max-width: 12rem;
95
98
  overflow: hidden;
96
99
  color: rgb(255 255 255 / 0.68);
@@ -0,0 +1,97 @@
1
+ # signature-pad
2
+
3
+ Área de firma con tinta SVG suavizada, captura de puntero para ratón, lápiz y tacto, historial de trazos y exportación. Las coordenadas normalizadas conservan la firma cuando cambia el tamaño. No necesita un contexto canvas para dibujar ni exportar SVG.
4
+
5
+ ```ts
6
+ import { createSignaturePad } from '@yewdiel21/motion-kit';
7
+ import '@yewdiel21/motion-kit/css/components/signature-pad';
8
+
9
+ const pad = createSignaturePad(document.querySelector('.mk-signature-pad'), {
10
+ label: 'Firma de entrega',
11
+ name: 'signature',
12
+ onChange(data, detail) {
13
+ console.log(data.strokes.length, detail.reason);
14
+ },
15
+ });
16
+
17
+ const saved = pad.exportData();
18
+ pad.clear();
19
+ pad.undo();
20
+ pad.importData(saved); // false si los datos no son válidos; no cambia la firma
21
+ const svg = pad.exportSVG({ width: 800, height: 300 });
22
+ const png = await pad.exportPNG({ background: '#ffffff', scale: 2 }); // Blob | null
23
+ pad.destroy();
24
+ ```
25
+
26
+ El contenedor vanilla es `<div class="mk-signature-pad"></div>`. Se añaden el área, las herramientas y el estado; `destroy()` elimina esos nodos, observadores, animaciones, capturas y eventos, y restaura los atributos originales. Los nodos originales del contenedor conservan su identidad. `createSignaturePad(null)` devuelve una instancia inerte.
27
+
28
+ ## React
29
+
30
+ ```tsx
31
+ import { useRef, useState } from 'react';
32
+ import { SignaturePad } from '@yewdiel21/motion-kit/react';
33
+ import type { SignatureData, SignaturePadInstance } from '@yewdiel21/motion-kit';
34
+
35
+ function Form() {
36
+ const [value, setValue] = useState<SignatureData>({ version: 1, strokes: [] });
37
+ const pad = useRef<SignaturePadInstance | null>(null);
38
+ return <SignaturePad value={value} onChange={setValue} instanceRef={pad} name="signature" />;
39
+ }
40
+ ```
41
+
42
+ `value` controla los datos; acepta las ediciones desde `onChange` para conservarlas. Si el propietario mantiene el mismo valor, la firma vuelve a ese valor al renderizar. Un valor aceptado conserva el historial. `defaultValue` inicia una firma no controlada. Las actualizaciones externas no notifican `onChange`; reemplazar datos diferentes reinicia el historial. `instanceRef` expone la API y vuelve a `null` al desmontar. `disabled` y `readOnly` cambian sin perder datos ni historial. Cambiar otras opciones reconstruye el área conservando los datos, pero reinicia el historial.
43
+
44
+ ## Custom element / Astro
45
+
46
+ ```html
47
+ <mk-signature-pad
48
+ class="mk-signature-pad"
49
+ label="Firma de entrega"
50
+ name="signature"
51
+ width="2.4"
52
+ ></mk-signature-pad>
53
+ ```
54
+
55
+ Registra `defineSignaturePad()` desde `@yewdiel21/motion-kit/elements`, o usa el registro general de elementos. `MkSignaturePadElement` expone `data`, `isEmpty`, `canUndo`, `canRedo`, `undo()`, `redo()`, `clear()`, `importData()`, `exportData()`, `exportSVG()` y `exportPNG()`. Los métodos operan mientras el elemento está conectado. El atributo `data` admite el JSON versionado; un cambio inválido conserva la firma anterior. Las opciones booleanas admiten presencia o `="false"`; la opción `readOnly` corresponde a `read-only`. Las ediciones sobreviven a desconexión y reconexión, aunque el historial se reinicia. Cambiar `disabled` o `read-only` conserva el historial; otras opciones lo reinician.
56
+
57
+ ## Opciones y API
58
+
59
+ | Opción | Valor inicial | Uso |
60
+ | --- | --- | --- |
61
+ | `data` | `{ version: 1, strokes: [] }` | Datos iniciales del motor. |
62
+ | `label` | `Área de firma` | Nombre accesible del dibujo. |
63
+ | `hint` | Instrucciones en español | Descripción visible y accesible. |
64
+ | `placeholder` | `Firma aquí` | Texto decorativo cuando está vacío. |
65
+ | `color` | Color de texto del tema | Color literal CSS de los trazos nuevos. |
66
+ | `width` | `2.4` | Ancho de tinta en píxeles CSS, limitado a 0.1–64. |
67
+ | `toolbar` | `true` | Botones nativos Deshacer, Rehacer y Limpiar. |
68
+ | `disabled` / `readOnly` | `false` | Bloquean dibujo y herramientas de edición. |
69
+ | `name` | Sin input | Input oculto con el JSON completo. Se excluye del formulario si está deshabilitado. |
70
+ | `historyLimit` | `50` | Entre 1 y 100 pasos de deshacer. |
71
+ | `onChange(data, detail)` | — | Una notificación por trazo completo o cambio de API. |
72
+
73
+ `data` y `exportData()` devuelven copias profundas. `importData(unknown, notify = true)` valida todo antes de reemplazar; devuelve `false` si falla, sin alterar datos, historial ni el trazo activo. Una importación diferente reinicia deshacer/rehacer. Importar los mismos datos conserva el historial y no notifica. `undo()`, `redo()` y `clear()` son operaciones reversibles; dibujar después de deshacer elimina la rama de rehacer. Un trazo cancelado, la pérdida de captura, el desenfoque o la desactivación descartan el trazo incompleto. Las exportaciones incluyen solo trazos terminados.
74
+
75
+ `setDisabled(boolean)` y `setReadOnly(boolean)` actualizan el modo del motor. Las llamadas de API pueden modificar datos incluso en modo deshabilitado o de solo lectura; esos modos bloquean la interacción del usuario. Las llamadas que cambian datos no hacen nada después de `destroy()`.
76
+
77
+ El evento burbujeante `mk:change` contiene `SignaturePadChangeDetail` en `event.detail`: `{ data, reason, isEmpty, canUndo, canRedo }`. `reason` es `draw`, `undo`, `redo`, `clear`, `import` o `reset`. El reset nativo de un formulario restaura los datos iniciales y limpia el historial; respeta un evento cancelado. Los datos entregados al callback y al evento son copias independientes de la firma interna. No se notifica al montar ni durante el movimiento del puntero.
78
+
79
+ ## Datos y exportaciones
80
+
81
+ ```ts
82
+ interface SignaturePoint { x: number; y: number } // ambos 0–1
83
+ interface SignatureStroke { points: SignaturePoint[]; color: string; width: number }
84
+ interface SignatureData { version: 1; strokes: SignatureStroke[] }
85
+ ```
86
+
87
+ Se aceptan como máximo 500 trazos y 50 000 puntos en total. Se rechazan versiones desconocidas, números no finitos, coordenadas fuera de 0–1, trazos sin puntos, anchos fuera de 0.1–64 y colores inválidos. Los colores admiten valores literales hexadecimales, `rgb()` / `hsl()` y nombres CSS; no admiten variables, `currentColor`, URLs ni servidores de pintura. La presión del lápiz no cambia el ancho: todos los trazos tienen un ancho constante y terminaciones redondeadas.
88
+
89
+ `exportSVG({ width?, height?, background? })` devuelve un SVG independiente sin guías, texto de ayuda ni controles. El tamaño predeterminado es el área actual, o 640 × 240 antes del primer layout. Los tamaños explícitos se limitan a 1–4096 px. El fondo es transparente salvo que pases un color. Las posiciones se escalan al tamaño solicitado; el ancho de tinta sigue expresado en píxeles. Cambiar la proporción del área estira la geometría de la firma proporcionalmente a cada eje.
90
+
91
+ `exportPNG({ width?, height?, background?, scale? })` rasteriza el mismo SVG mediante canvas e imagen del navegador. `scale` vale 2 por defecto y se limita a 1–4. Devuelve `Promise<Blob | null>`: `null` si no hay canvas 2D, la imagen no carga, el navegador impide la rasterización, pasan 10 segundos o se destruye la instancia. La política CSP de la página debe permitir imágenes `data:` para esta función. SVG y JSON siguen disponibles sin canvas. PNG no tiene dependencias ni peticiones de red.
92
+
93
+ ## Tema, interacción y accesibilidad
94
+
95
+ La tarjeta usa `--mk-surface-*`, `--mk-outline-soft`, `--mk-text`, `--mk-muted`, `--mk-font` y `--mk-accent`. Personaliza `--mk-signature-height`, `--mk-signature-paper` y `--mk-signature-ink`; esta última debe contener un color literal y afecta los trazos nuevos. El color ya capturado se conserva con los datos.
96
+
97
+ Los controles nativos están etiquetados, usan foco visible y se recorren con Tab; Enter o Espacio activan sus acciones. El SVG tiene nombre y descripción, y un estado `aria-live="polite"` anuncia el número de trazos. Dibujar a mano requiere puntero y no tiene equivalente completo de teclado; cuando tu flujo necesite otra forma de firmar, proporciona una entrada de nombre o una alternativa adecuada junto al componente. Solo el área de dibujo bloquea los gestos táctiles; las herramientas permiten desplazarse normalmente. El modo de solo lectura y el deshabilitado permiten los gestos de desplazamiento sobre el área. GSAP da un pequeño rebote a las herramientas; con movimiento reducido se omite. La tinta sigue el puntero inmediatamente en ambos modos.
@@ -0,0 +1,57 @@
1
+ import { MkElement, defineElement, type OptionType } from '../../elements/base';
2
+ import { createSignaturePad, type SignatureData, type SignatureExportOptions, type SignaturePNGOptions, type SignaturePadInstance, type SignaturePadOptions } from './engine';
3
+
4
+ /** Light-DOM element. `data` accepts versioned JSON; mk:change carries data and history state. */
5
+ export class MkSignaturePadElement extends MkElement<SignaturePadInstance> {
6
+ static options: Record<string, OptionType> = {
7
+ data: 'json', label: 'string', hint: 'string', placeholder: 'string', color: 'string', width: 'number',
8
+ disabled: 'boolean', readOnly: 'boolean', toolbar: 'boolean', name: 'string', historyLimit: 'number',
9
+ };
10
+ private saved: SignatureData | undefined;
11
+
12
+ protected create(options: Record<string, unknown>) {
13
+ return createSignaturePad(this, { ...options, data: options.data ?? this.saved } as SignaturePadOptions);
14
+ }
15
+
16
+ attributeChangedCallback(name: string, previous: string | null, next: string | null) {
17
+ if (previous === next || !this.instance || !this.isConnected) return;
18
+ if (name === 'disabled') { this.instance.setDisabled(next !== null && next !== 'false'); return; }
19
+ if (name === 'read-only') { this.instance.setReadOnly(next !== null && next !== 'false'); return; }
20
+ if (name === 'data') {
21
+ try { this.instance.importData(next === null ? { version: 1, strokes: [] } : JSON.parse(next), false); }
22
+ catch { /* Invalid JSON leaves the signature untouched. */ }
23
+ return;
24
+ }
25
+ this.saved = this.instance.exportData();
26
+ // Preserve live edits when an unrelated option changes, even with initial data markup.
27
+ this.instance.destroy();
28
+ this.instance = createSignaturePad(this, { ...this.options, data: this.saved } as SignaturePadOptions);
29
+ }
30
+
31
+ disconnectedCallback() {
32
+ this.saved = this.instance?.exportData() ?? this.saved;
33
+ super.disconnectedCallback();
34
+ }
35
+
36
+ connectedCallback() {
37
+ if (!this.instance) this.instance = createSignaturePad(this, { ...this.options, data: this.saved ?? this.options.data } as SignaturePadOptions);
38
+ }
39
+
40
+ get data(): SignatureData {
41
+ if (this.instance) return this.instance.exportData();
42
+ return this.saved ? { version: 1, strokes: this.saved.strokes.map((stroke) => ({ ...stroke, points: stroke.points.map((point) => ({ ...point })) })) } : { version: 1, strokes: [] };
43
+ }
44
+ set data(value: SignatureData) { this.importData(value); }
45
+ get isEmpty() { return this.instance?.isEmpty ?? !this.saved?.strokes.length; }
46
+ get canUndo() { return this.instance?.canUndo ?? false; }
47
+ get canRedo() { return this.instance?.canRedo ?? false; }
48
+ undo() { this.instance?.undo(); }
49
+ redo() { this.instance?.redo(); }
50
+ clear() { this.instance?.clear(); }
51
+ importData(value: unknown, notify = true) { return this.instance?.importData(value, notify) ?? false; }
52
+ exportData() { return this.instance?.exportData() ?? this.data; }
53
+ exportSVG(options?: SignatureExportOptions) { return this.instance?.exportSVG(options) ?? ''; }
54
+ exportPNG(options?: SignaturePNGOptions) { return this.instance?.exportPNG(options) ?? Promise.resolve(null); }
55
+ }
56
+
57
+ export const defineSignaturePad = () => defineElement('mk-signature-pad', MkSignaturePadElement);