@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.
- package/README.md +25 -0
- package/dist/auto/index.d.ts +8 -3
- package/dist/auto/index.js +13 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-A6QQCF6V.js → chunk-7IQTMF7Y.js} +1542 -54
- package/dist/chunk-7IQTMF7Y.js.map +1 -0
- package/dist/{chunk-6IRJYXJU.js → chunk-BZLMCHUD.js} +2 -2
- package/dist/{chunk-JXRS3OEA.js → chunk-VJFPTLAK.js} +29 -7
- package/dist/chunk-VJFPTLAK.js.map +1 -0
- package/dist/css/components/chart.css +7 -0
- package/dist/css/components/drilldown-menu.css +130 -0
- package/dist/css/components/heatmap-chart.css +7 -0
- package/dist/css/components/pie-chart.css +7 -0
- package/dist/css/components/radar-chart.css +7 -0
- package/dist/css/components/radial-chart.css +16 -2
- package/dist/css/components/sankey-chart.css +7 -0
- package/dist/css/components/scroll-island.css +3 -0
- package/dist/css/components/signature-pad.css +104 -0
- package/dist/css/components/voice-message-bubble.css +50 -0
- package/dist/css/components/waveform-player.css +36 -0
- package/dist/css/components/wheel-picker.css +17 -0
- package/dist/elements/index.d.ts +90 -4
- package/dist/elements/index.js +290 -8
- package/dist/elements/index.js.map +1 -1
- package/dist/{reveal-BfyKKsw4.d.ts → engine-Bn6hC87X.d.ts} +206 -1
- package/dist/index.css +324 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +48 -10
- package/dist/react/index.js +240 -10
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-6X_bns60.d.ts → reduced-YS6TZjpa.d.ts} +1 -1
- package/dist/{store-CA2Qcun_.d.ts → store-CZ6vdtIE.d.ts} +5 -1
- package/package.json +110 -110
- package/registry.json +64 -0
- package/src/auto/auto-init.ts +308 -293
- package/src/components/add-to-cart/README.md +49 -34
- package/src/components/add-to-cart/react.tsx +4 -0
- package/src/components/chart/README.md +49 -34
- package/src/components/chart/chart.css +7 -0
- package/src/components/chart/element.ts +2 -1
- package/src/components/chart/engine.ts +105 -13
- package/src/components/chart/react.tsx +2 -2
- package/src/components/drilldown-menu/README.md +163 -0
- package/src/components/drilldown-menu/drilldown-menu.css +129 -0
- package/src/components/drilldown-menu/element.ts +59 -0
- package/src/components/drilldown-menu/engine.ts +338 -0
- package/src/components/drilldown-menu/react.tsx +76 -0
- package/src/components/radial-chart/README.md +10 -0
- package/src/components/radial-chart/element.ts +2 -2
- package/src/components/radial-chart/engine.ts +33 -6
- package/src/components/radial-chart/radial-chart.css +9 -2
- package/src/components/radial-chart/react.tsx +2 -2
- package/src/components/scroll-island/README.md +44 -34
- package/src/components/scroll-island/element.ts +1 -1
- package/src/components/scroll-island/engine.ts +25 -3
- package/src/components/scroll-island/react.tsx +3 -3
- package/src/components/scroll-island/scroll-island.css +3 -0
- package/src/components/signature-pad/README.md +97 -0
- package/src/components/signature-pad/element.ts +57 -0
- package/src/components/signature-pad/engine.ts +437 -0
- package/src/components/signature-pad/react.tsx +56 -0
- package/src/components/signature-pad/signature-pad.css +103 -0
- package/src/components/voice-message-bubble/README.md +31 -0
- package/src/components/voice-message-bubble/element.ts +20 -0
- package/src/components/voice-message-bubble/engine.ts +68 -0
- package/src/components/voice-message-bubble/react.tsx +27 -0
- package/src/components/voice-message-bubble/voice-message-bubble.css +12 -0
- package/src/components/waveform-player/README.md +30 -0
- package/src/components/waveform-player/element.ts +21 -0
- package/src/components/waveform-player/engine.ts +261 -0
- package/src/components/waveform-player/react.tsx +27 -0
- package/src/components/waveform-player/waveform-player.css +35 -0
- package/src/components/wheel-picker/README.md +33 -0
- package/src/components/wheel-picker/element.ts +18 -0
- package/src/components/wheel-picker/engine.ts +210 -0
- package/src/components/wheel-picker/react.tsx +32 -0
- package/src/components/wheel-picker/wheel-picker.css +16 -0
- package/src/elements/index.ts +273 -258
- package/src/index.ts +295 -290
- package/src/react/index.ts +176 -171
- package/dist/chunk-A6QQCF6V.js.map +0 -1
- package/dist/chunk-JXRS3OEA.js.map +0 -1
- /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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
</
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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);
|