@yewdiel21/motion-kit 0.16.0 → 0.18.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 +44 -0
- package/dist/auto/index.d.ts +23 -3
- package/dist/auto/index.js +43 -3
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-RBEKR5UZ.js → chunk-AWDEKDEJ.js} +2 -2
- package/dist/{chunk-NFZIX3IA.js → chunk-EDMXWUW3.js} +2 -2
- package/dist/{chunk-SDQ776LG.js → chunk-Q7IW4FV3.js} +3941 -43
- package/dist/chunk-Q7IW4FV3.js.map +1 -0
- package/dist/css/components/badge-button.css +16 -0
- package/dist/css/components/bento-grid.css +13 -0
- package/dist/css/components/drilldown-menu.css +134 -0
- package/dist/css/components/equalizer-slider.css +26 -0
- package/dist/css/components/handwriting-text.css +15 -0
- package/dist/css/components/magnetize-button.css +18 -0
- package/dist/css/components/morphing-page-dots.css +13 -0
- package/dist/css/components/not-found.css +22 -0
- package/dist/css/components/notification-stack.css +42 -0
- package/dist/css/components/particle-button.css +14 -0
- package/dist/css/components/radio-group.css +29 -0
- package/dist/css/components/rating-scale.css +23 -0
- package/dist/css/components/scroll-preview-dock.css +18 -0
- package/dist/css/components/select.css +16 -0
- package/dist/css/components/signature-pad.css +131 -0
- package/dist/css/components/stepper.css +36 -0
- package/dist/css/components/sticky-header.css +10 -0
- package/dist/css/components/temperature-slider.css +29 -0
- package/dist/css/components/voice-message-bubble.css +67 -0
- package/dist/css/components/waveform-player.css +51 -0
- package/dist/css/components/wheel-picker.css +18 -0
- package/dist/css/tokens.css +4 -0
- package/dist/elements/index.d.ts +292 -4
- package/dist/elements/index.js +856 -4
- package/dist/elements/index.js.map +1 -1
- package/dist/{reveal-DRpNO4KP.d.ts → engine-t8d7brjn.d.ts} +534 -1
- package/dist/index.css +713 -0
- package/dist/index.d.ts +23 -23
- package/dist/index.js +43 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +151 -7
- package/dist/react/index.js +893 -3
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-DQwVXQYb.d.ts → reduced-dPsZYqtI.d.ts} +1 -1
- package/dist/{store-Da3zSh8Z.d.ts → store-6hZNKiee.d.ts} +1 -1
- package/package.json +110 -110
- package/registry.json +244 -0
- package/src/auto/auto-init.ts +339 -293
- package/src/components/badge-button/README.md +28 -0
- package/src/components/badge-button/badge-button.css +15 -0
- package/src/components/badge-button/element.ts +16 -0
- package/src/components/badge-button/engine.ts +85 -0
- package/src/components/badge-button/react.tsx +31 -0
- package/src/components/bento-grid/README.md +26 -0
- package/src/components/bento-grid/bento-grid.css +12 -0
- package/src/components/bento-grid/element.ts +22 -0
- package/src/components/bento-grid/engine.ts +153 -0
- package/src/components/bento-grid/react.tsx +25 -0
- package/src/components/drilldown-menu/README.md +163 -0
- package/src/components/drilldown-menu/drilldown-menu.css +133 -0
- package/src/components/drilldown-menu/element.ts +59 -0
- package/src/components/drilldown-menu/engine.ts +378 -0
- package/src/components/drilldown-menu/react.tsx +76 -0
- package/src/components/equalizer-slider/README.md +43 -0
- package/src/components/equalizer-slider/element.ts +41 -0
- package/src/components/equalizer-slider/engine.ts +32 -0
- package/src/components/equalizer-slider/equalizer-slider.css +26 -0
- package/src/components/equalizer-slider/react.tsx +60 -0
- package/src/components/equalizer-slider/shared.ts +161 -0
- package/src/components/handwriting-text/README.md +29 -0
- package/src/components/handwriting-text/element.ts +14 -0
- package/src/components/handwriting-text/engine.ts +108 -0
- package/src/components/handwriting-text/glyphs.ts +71 -0
- package/src/components/handwriting-text/handwriting-text.css +14 -0
- package/src/components/handwriting-text/react.tsx +23 -0
- package/src/components/magnetize-button/README.md +26 -0
- package/src/components/magnetize-button/element.ts +13 -0
- package/src/components/magnetize-button/engine.ts +84 -0
- package/src/components/magnetize-button/magnetize-button.css +17 -0
- package/src/components/magnetize-button/react.tsx +22 -0
- package/src/components/morphing-page-dots/README.md +28 -0
- package/src/components/morphing-page-dots/element.ts +16 -0
- package/src/components/morphing-page-dots/engine.ts +89 -0
- package/src/components/morphing-page-dots/morphing-page-dots.css +12 -0
- package/src/components/morphing-page-dots/react.tsx +32 -0
- package/src/components/not-found/README.md +26 -0
- package/src/components/not-found/element.ts +15 -0
- package/src/components/not-found/engine.ts +57 -0
- package/src/components/not-found/not-found.css +21 -0
- package/src/components/not-found/react.tsx +23 -0
- package/src/components/notification-stack/README.md +24 -0
- package/src/components/notification-stack/element.ts +26 -0
- package/src/components/notification-stack/engine.ts +329 -0
- package/src/components/notification-stack/notification-stack.css +41 -0
- package/src/components/notification-stack/react.tsx +28 -0
- package/src/components/particle-button/README.md +26 -0
- package/src/components/particle-button/element.ts +13 -0
- package/src/components/particle-button/engine.ts +52 -0
- package/src/components/particle-button/particle-button.css +13 -0
- package/src/components/particle-button/react.tsx +22 -0
- package/src/components/radio-group/README.md +43 -0
- package/src/components/radio-group/element.ts +41 -0
- package/src/components/radio-group/engine.ts +11 -0
- package/src/components/radio-group/radio-group.css +28 -0
- package/src/components/radio-group/react.tsx +60 -0
- package/src/components/radio-group/shared.ts +118 -0
- package/src/components/rating-scale/README.md +43 -0
- package/src/components/rating-scale/element.ts +41 -0
- package/src/components/rating-scale/engine.ts +63 -0
- package/src/components/rating-scale/rating-scale.css +22 -0
- package/src/components/rating-scale/react.tsx +60 -0
- package/src/components/scroll-preview-dock/README.md +24 -0
- package/src/components/scroll-preview-dock/element.ts +20 -0
- package/src/components/scroll-preview-dock/engine.ts +228 -0
- package/src/components/scroll-preview-dock/react.tsx +26 -0
- package/src/components/scroll-preview-dock/scroll-preview-dock.css +17 -0
- package/src/components/select/README.md +23 -9
- package/src/components/select/select.css +16 -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 +550 -0
- package/src/components/signature-pad/react.tsx +56 -0
- package/src/components/signature-pad/signature-pad.css +130 -0
- package/src/components/stepper/README.md +46 -0
- package/src/components/stepper/element.ts +42 -0
- package/src/components/stepper/engine.ts +103 -0
- package/src/components/stepper/react.tsx +60 -0
- package/src/components/stepper/stepper.css +35 -0
- package/src/components/sticky-header/README.md +30 -0
- package/src/components/sticky-header/element.ts +19 -0
- package/src/components/sticky-header/engine.ts +95 -0
- package/src/components/sticky-header/react.tsx +25 -0
- package/src/components/sticky-header/sticky-header.css +9 -0
- package/src/components/temperature-slider/README.md +42 -0
- package/src/components/temperature-slider/element.ts +53 -0
- package/src/components/temperature-slider/engine.ts +44 -0
- package/src/components/temperature-slider/react.tsx +66 -0
- package/src/components/temperature-slider/temperature-slider.css +29 -0
- package/src/components/voice-message-bubble/README.md +31 -0
- package/src/components/voice-message-bubble/element.ts +20 -0
- package/src/components/voice-message-bubble/engine.ts +79 -0
- package/src/components/voice-message-bubble/react.tsx +27 -0
- package/src/components/voice-message-bubble/voice-message-bubble.css +14 -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 +344 -0
- package/src/components/waveform-player/react.tsx +27 -0
- package/src/components/waveform-player/waveform-player.css +50 -0
- package/src/components/wheel-picker/README.md +35 -0
- package/src/components/wheel-picker/element.ts +25 -0
- package/src/components/wheel-picker/engine.ts +239 -0
- package/src/components/wheel-picker/react.tsx +32 -0
- package/src/components/wheel-picker/wheel-picker.css +17 -0
- package/src/css/tokens.css +4 -0
- package/src/elements/index.ts +318 -258
- package/src/index.ts +21 -0
- package/src/react/index.ts +192 -171
- package/dist/chunk-SDQ776LG.js.map +0 -1
- /package/dist/{chunk-RBEKR5UZ.js.map → chunk-AWDEKDEJ.js.map} +0 -0
- /package/dist/{chunk-NFZIX3IA.js.map → chunk-EDMXWUW3.js.map} +0 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { gsap, EASE, registerMotion } from '../../core/gsap';
|
|
2
|
+
import { createScope } from '../../core/scope';
|
|
3
|
+
import { reduced } from '../../core/reduced';
|
|
4
|
+
import { inert, type Instance } from '../../core/types';
|
|
5
|
+
import { blurIn, press } from '../../motion/micro';
|
|
6
|
+
|
|
7
|
+
export interface ScrollPreviewDockItem {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
thumbnail?: string;
|
|
12
|
+
/** A section element or selector. Defaults to the element with this item's id. */
|
|
13
|
+
section?: HTMLElement | string;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface ScrollPreviewDockOptions {
|
|
17
|
+
items?: ScrollPreviewDockItem[];
|
|
18
|
+
scroller?: HTMLElement | string | null;
|
|
19
|
+
offset?: number;
|
|
20
|
+
label?: string;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
onChange?: (id: string) => void;
|
|
23
|
+
}
|
|
24
|
+
export interface ScrollPreviewDockInstance extends Instance {
|
|
25
|
+
select(id: string): void;
|
|
26
|
+
update(): void;
|
|
27
|
+
setOptions(options: Partial<ScrollPreviewDockOptions>): void;
|
|
28
|
+
readonly value: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Native section links, a travelling selection pill, and a focus/hover preview. */
|
|
32
|
+
export function createScrollPreviewDock(root: HTMLElement | null, options: ScrollPreviewDockOptions = {}): ScrollPreviewDockInstance {
|
|
33
|
+
if (!root) return inert({ select() {}, update() {}, setOptions() {}, value: '' });
|
|
34
|
+
registerMotion();
|
|
35
|
+
const scope = createScope();
|
|
36
|
+
let opts = { ...options };
|
|
37
|
+
let current = '';
|
|
38
|
+
let previewed = '';
|
|
39
|
+
let frame = 0;
|
|
40
|
+
let scrolling: gsap.core.Tween | null = null;
|
|
41
|
+
let unbindScroller = () => {};
|
|
42
|
+
let scroller: HTMLElement | null = null;
|
|
43
|
+
const original = [...root.childNodes];
|
|
44
|
+
const animations = new Set<gsap.core.Animation>();
|
|
45
|
+
const track = (animation: gsap.core.Animation | null) => { if (animation) animations.add(animation); };
|
|
46
|
+
const panel = document.createElement('div');
|
|
47
|
+
panel.className = 'mk-scroll-preview-dock__preview';
|
|
48
|
+
panel.setAttribute('aria-hidden', 'true');
|
|
49
|
+
const image = document.createElement('span');
|
|
50
|
+
image.className = 'mk-scroll-preview-dock__image';
|
|
51
|
+
const copy = document.createElement('span');
|
|
52
|
+
const title = document.createElement('strong');
|
|
53
|
+
const description = document.createElement('span');
|
|
54
|
+
description.className = 'mk-scroll-preview-dock__description';
|
|
55
|
+
copy.append(title, description);
|
|
56
|
+
panel.append(image, copy);
|
|
57
|
+
const nav = document.createElement('nav');
|
|
58
|
+
nav.className = 'mk-scroll-preview-dock__nav';
|
|
59
|
+
const pill = document.createElement('span');
|
|
60
|
+
pill.className = 'mk-scroll-preview-dock__pill';
|
|
61
|
+
pill.setAttribute('aria-hidden', 'true');
|
|
62
|
+
const empty = document.createElement('p');
|
|
63
|
+
empty.className = 'mk-scroll-preview-dock__empty';
|
|
64
|
+
empty.textContent = 'No hay secciones para explorar.';
|
|
65
|
+
root.replaceChildren(panel, nav, empty);
|
|
66
|
+
scope.attr(root, 'class', `${root.className} mk-scroll-preview-dock`.trim());
|
|
67
|
+
scope.attr(root, 'data-mk-ready', '');
|
|
68
|
+
const items = () => opts.items ?? [];
|
|
69
|
+
const enabled = (item: ScrollPreviewDockItem) => !opts.disabled && !item.disabled;
|
|
70
|
+
const section = (item: ScrollPreviewDockItem) => {
|
|
71
|
+
if (item.section instanceof HTMLElement) return item.section;
|
|
72
|
+
if (!item.section) return document.getElementById(item.id);
|
|
73
|
+
try { return (scroller ?? document).querySelector<HTMLElement>(item.section); } catch { return null; }
|
|
74
|
+
};
|
|
75
|
+
const links = () => [...nav.querySelectorAll<HTMLAnchorElement>('a')];
|
|
76
|
+
const thumb = (node: HTMLElement, item: ScrollPreviewDockItem) => {
|
|
77
|
+
node.replaceChildren();
|
|
78
|
+
if (item.thumbnail) { const img = document.createElement('img'); img.src = item.thumbnail; img.alt = ''; img.draggable = false; node.append(img); }
|
|
79
|
+
else { const letter = document.createElement('span'); letter.textContent = item.label.slice(0, 2); node.append(letter); }
|
|
80
|
+
};
|
|
81
|
+
const preview = (id: string, animate = true) => {
|
|
82
|
+
const item = items().find((item) => item.id === id);
|
|
83
|
+
if (!item || previewed === id) return;
|
|
84
|
+
previewed = id;
|
|
85
|
+
thumb(image, item);
|
|
86
|
+
title.textContent = item.label;
|
|
87
|
+
description.textContent = item.description ?? 'Explorar sección';
|
|
88
|
+
for (const link of links()) link.dataset.mkPreview = String(link.dataset.mkSection === id);
|
|
89
|
+
if (animate) { gsap.killTweensOf(panel); track(blurIn(panel, { from: 'soft', duration: .28 })); }
|
|
90
|
+
};
|
|
91
|
+
const place = (animate = true) => {
|
|
92
|
+
const active = links().find((link) => link.dataset.mkSection === current);
|
|
93
|
+
pill.hidden = !active;
|
|
94
|
+
if (!active) return;
|
|
95
|
+
track(gsap.to(pill, { x: active.offsetLeft, y: active.offsetTop, width: active.offsetWidth, height: active.offsetHeight, duration: reduced() || !animate ? 0 : .45, ease: EASE.swatch, overwrite: 'auto' }));
|
|
96
|
+
};
|
|
97
|
+
const activate = (id: string, notify = true) => {
|
|
98
|
+
if (scope.disposed || current === id) return;
|
|
99
|
+
current = id;
|
|
100
|
+
for (const link of links()) {
|
|
101
|
+
if (link.dataset.mkSection === id) link.setAttribute('aria-current', 'location');
|
|
102
|
+
else link.removeAttribute('aria-current');
|
|
103
|
+
}
|
|
104
|
+
place();
|
|
105
|
+
if (!root.contains(document.activeElement) && !root.matches(':hover')) preview(id);
|
|
106
|
+
else if (!previewed) preview(id, false);
|
|
107
|
+
if (notify) {
|
|
108
|
+
opts.onChange?.(id);
|
|
109
|
+
root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { value: id } }));
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const update = () => {
|
|
113
|
+
frame = 0;
|
|
114
|
+
if (scope.disposed) return;
|
|
115
|
+
const top = scroller ? scroller.getBoundingClientRect().top + scroller.clientTop : 0;
|
|
116
|
+
const height = scroller ? scroller.clientHeight : window.innerHeight;
|
|
117
|
+
const line = top + Math.min(120, height * .3) + (opts.offset ?? 0);
|
|
118
|
+
const targets = items().filter(enabled).map((item) => ({ item, node: section(item) })).filter((entry): entry is { item: ScrollPreviewDockItem; node: HTMLElement } => !!entry.node);
|
|
119
|
+
let target: (typeof targets)[number] | undefined = targets[0];
|
|
120
|
+
for (const entry of targets) if (entry.node.getBoundingClientRect().top <= line) target = entry;
|
|
121
|
+
const atEnd = scroller ? scroller.scrollHeight > scroller.clientHeight && scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2 : document.documentElement.scrollHeight > window.innerHeight && window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 2;
|
|
122
|
+
if (atEnd) target = targets.at(-1);
|
|
123
|
+
if (target) activate(target.item.id);
|
|
124
|
+
place(false);
|
|
125
|
+
};
|
|
126
|
+
const queue = () => { if (!frame) frame = requestAnimationFrame(update); };
|
|
127
|
+
const bindScroller = () => {
|
|
128
|
+
unbindScroller();
|
|
129
|
+
scroller = typeof opts.scroller === 'string' ? (() => { try { return root.closest<HTMLElement>(opts.scroller) ?? document.querySelector<HTMLElement>(opts.scroller); } catch { return null; } })() : opts.scroller ?? null;
|
|
130
|
+
const target = scroller ?? window;
|
|
131
|
+
target.addEventListener('scroll', queue, { passive: true });
|
|
132
|
+
target.addEventListener('wheel', cancelScroll, { passive: true });
|
|
133
|
+
target.addEventListener('touchstart', cancelScroll, { passive: true });
|
|
134
|
+
unbindScroller = () => { target.removeEventListener('scroll', queue); target.removeEventListener('wheel', cancelScroll); target.removeEventListener('touchstart', cancelScroll); };
|
|
135
|
+
};
|
|
136
|
+
function cancelScroll() { scrolling?.kill(); scrolling = null; }
|
|
137
|
+
const select = (id: string) => {
|
|
138
|
+
if (scope.disposed) return;
|
|
139
|
+
const item = items().find((entry) => entry.id === id);
|
|
140
|
+
if (!item || !enabled(item)) return;
|
|
141
|
+
const target = section(item);
|
|
142
|
+
if (!target) return;
|
|
143
|
+
cancelScroll();
|
|
144
|
+
const top = Math.max(0, (scroller ? scroller.scrollTop + target.getBoundingClientRect().top - scroller.getBoundingClientRect().top - scroller.clientTop : window.scrollY + target.getBoundingClientRect().top) - (opts.offset ?? 0));
|
|
145
|
+
activate(id);
|
|
146
|
+
preview(id);
|
|
147
|
+
if (scroller) {
|
|
148
|
+
if (reduced()) scroller.scrollTop = top;
|
|
149
|
+
else scrolling = gsap.to(scroller, { scrollTop: top, duration: .65, ease: EASE.signature, overwrite: 'auto', onUpdate: queue });
|
|
150
|
+
} else if (reduced()) window.scrollTo({ top, behavior: 'instant' });
|
|
151
|
+
else {
|
|
152
|
+
const position = { top: window.scrollY };
|
|
153
|
+
scrolling = gsap.to(position, { top, duration: .65, ease: EASE.signature, onUpdate: () => { window.scrollTo(0, position.top); queue(); } });
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
const render = () => {
|
|
157
|
+
gsap.killTweensOf(pill);
|
|
158
|
+
nav.replaceChildren(pill);
|
|
159
|
+
nav.setAttribute('aria-label', opts.label ?? 'Explorar secciones');
|
|
160
|
+
const unique = new Set<string>();
|
|
161
|
+
for (const item of items()) {
|
|
162
|
+
if (!item.id || unique.has(item.id)) continue;
|
|
163
|
+
unique.add(item.id);
|
|
164
|
+
const link = document.createElement('a');
|
|
165
|
+
link.href = `#${encodeURIComponent(section(item)?.id || item.id)}`;
|
|
166
|
+
link.dataset.mkSection = item.id;
|
|
167
|
+
link.className = 'mk-scroll-preview-dock__item';
|
|
168
|
+
const cover = document.createElement('span');
|
|
169
|
+
cover.className = 'mk-scroll-preview-dock__thumb';
|
|
170
|
+
cover.setAttribute('aria-hidden', 'true');
|
|
171
|
+
thumb(cover, item);
|
|
172
|
+
const label = document.createElement('span');
|
|
173
|
+
label.textContent = item.label;
|
|
174
|
+
link.append(cover, label);
|
|
175
|
+
if (!enabled(item)) { link.setAttribute('aria-disabled', 'true'); link.tabIndex = -1; }
|
|
176
|
+
nav.append(link);
|
|
177
|
+
}
|
|
178
|
+
empty.hidden = unique.size > 0;
|
|
179
|
+
panel.hidden = unique.size === 0;
|
|
180
|
+
nav.hidden = unique.size === 0;
|
|
181
|
+
scope.attr(root, 'data-mk-disabled', opts.disabled ? '' : null);
|
|
182
|
+
previewed = '';
|
|
183
|
+
const next = items().find((item) => item.id === current && enabled(item)) ?? items().find(enabled);
|
|
184
|
+
current = '';
|
|
185
|
+
if (next) { activate(next.id, false); preview(next.id, false); }
|
|
186
|
+
else pill.hidden = true;
|
|
187
|
+
};
|
|
188
|
+
scope.on(nav, 'click', (event) => {
|
|
189
|
+
const link = (event.target as Element).closest<HTMLAnchorElement>('a[data-mk-section]');
|
|
190
|
+
if (!link) return;
|
|
191
|
+
if (link.getAttribute('aria-disabled') === 'true') { event.preventDefault(); return; }
|
|
192
|
+
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey || event.button !== 0) return;
|
|
193
|
+
const item = items().find((item) => item.id === link.dataset.mkSection);
|
|
194
|
+
if (!item || !section(item)) return;
|
|
195
|
+
event.preventDefault(); track(press(link)); select(item.id);
|
|
196
|
+
});
|
|
197
|
+
scope.on(nav, 'pointerover', (event) => { const link = (event.target as Element).closest<HTMLElement>('a[data-mk-section]'); if (link && link.getAttribute('aria-disabled') !== 'true') preview(link.dataset.mkSection!); });
|
|
198
|
+
scope.on(nav, 'focusin', (event) => { const link = (event.target as Element).closest<HTMLElement>('a[data-mk-section]'); if (link) preview(link.dataset.mkSection!); });
|
|
199
|
+
scope.on(root, 'pointerleave', () => { if (!root.contains(document.activeElement)) preview(current); });
|
|
200
|
+
scope.on(root, 'focusout', (event) => { if (!root.contains(event.relatedTarget as Node | null)) preview(current); });
|
|
201
|
+
scope.on(nav, 'keydown', (event) => {
|
|
202
|
+
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) { if (['PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', ' '].includes(event.key)) cancelScroll(); return; }
|
|
203
|
+
const available = links().filter((link) => link.getAttribute('aria-disabled') !== 'true');
|
|
204
|
+
const index = available.indexOf(document.activeElement as HTMLAnchorElement);
|
|
205
|
+
if (index < 0 || !available.length) return;
|
|
206
|
+
event.preventDefault();
|
|
207
|
+
available[event.key === 'Home' ? 0 : event.key === 'End' ? available.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + available.length) % available.length]?.focus();
|
|
208
|
+
});
|
|
209
|
+
scope.on(window, 'resize', queue, { passive: true });
|
|
210
|
+
if (typeof ResizeObserver === 'function') { const resize = scope.observe(new ResizeObserver(queue)); resize.observe(nav); }
|
|
211
|
+
bindScroller(); render(); update();
|
|
212
|
+
return {
|
|
213
|
+
select, update,
|
|
214
|
+
get value() { return current; },
|
|
215
|
+
setOptions(next) {
|
|
216
|
+
if (scope.disposed) return;
|
|
217
|
+
const focusedId = root.contains(document.activeElement)
|
|
218
|
+
? (document.activeElement as HTMLElement | null)?.closest<HTMLAnchorElement>('a[data-mk-section]')?.dataset.mkSection
|
|
219
|
+
: undefined;
|
|
220
|
+
opts = { ...opts, ...next };
|
|
221
|
+
if ('scroller' in next) { cancelScroll(); bindScroller(); }
|
|
222
|
+
render();
|
|
223
|
+
if (focusedId) { const target = links().find((link) => link.dataset.mkSection === focusedId && link.getAttribute('aria-disabled') !== 'true') ?? links().find((link) => link.getAttribute('aria-disabled') !== 'true'); if (target) target.focus(); else { scope.attr(root, 'tabindex', root.getAttribute('tabindex')); root.tabIndex = -1; root.focus(); } }
|
|
224
|
+
update();
|
|
225
|
+
},
|
|
226
|
+
destroy() { if (scope.disposed) return; cancelAnimationFrame(frame); cancelScroll(); unbindScroller(); animations.forEach((animation) => animation.kill()); gsap.killTweensOf([...root.querySelectorAll('*')]); scope.dispose(); root.replaceChildren(...original); },
|
|
227
|
+
};
|
|
228
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
|
|
3
|
+
import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
|
|
4
|
+
import { createScrollPreviewDock, type ScrollPreviewDockOptions, type ScrollPreviewDockInstance } from './engine';
|
|
5
|
+
|
|
6
|
+
export interface ScrollPreviewDockProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange' | 'children' | 'dangerouslySetInnerHTML'>, ScrollPreviewDockOptions {
|
|
7
|
+
instanceRef?: MutableRefObject<ScrollPreviewDockInstance | null>;
|
|
8
|
+
children?: never;
|
|
9
|
+
dangerouslySetInnerHTML?: never;
|
|
10
|
+
}
|
|
11
|
+
export function ScrollPreviewDock({ items, scroller, offset, label, disabled, onChange, instanceRef, className, children, dangerouslySetInnerHTML: _html, ...props }: ScrollPreviewDockProps) {
|
|
12
|
+
const root = useRef<HTMLDivElement>(null);
|
|
13
|
+
const instance = useRef<ScrollPreviewDockInstance | null>(null);
|
|
14
|
+
const onChangeRef = useLatest(onChange);
|
|
15
|
+
useIsomorphicLayoutEffect(() => {
|
|
16
|
+
const created = createScrollPreviewDock(root.current, { items, scroller, offset, label, disabled, onChange: (...args) => onChangeRef.current?.(...args) });
|
|
17
|
+
instance.current = created;
|
|
18
|
+
return () => { created.destroy(); instance.current = null; };
|
|
19
|
+
// Options update the existing engine so timers, selection and scroll state survive.
|
|
20
|
+
}, []);
|
|
21
|
+
useIsomorphicLayoutEffect(() => {
|
|
22
|
+
instance.current?.setOptions({ items, scroller, offset, label, disabled });
|
|
23
|
+
}, [items, scroller, offset, label, disabled]);
|
|
24
|
+
useIsomorphicLayoutEffect(() => { if (instanceRef) instanceRef.current = instance.current; return () => { if (instanceRef) instanceRef.current = null; }; }, [instanceRef]);
|
|
25
|
+
return <div {...props} ref={root} className={['mk-scroll-preview-dock', className].filter(Boolean).join(' ')}></div>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
.mk-scroll-preview-dock, mk-scroll-preview-dock { box-sizing: border-box; display: grid; gap: 10px; width: min(100%, 400px); color: var(--mk-text); font-family: var(--mk-font); }
|
|
2
|
+
.mk-scroll-preview-dock__preview { display: flex; align-items: center; gap: 12px; min-height: 60px; box-sizing: border-box; padding: 12px 16px; border-radius: 24px; background: var(--mk-surface-2); box-shadow: 0 1px 0 var(--mk-outline-soft) inset; }
|
|
3
|
+
.mk-scroll-preview-dock__image { display: grid; place-items: center; flex: 0 0 44px; width: 44px; height: 44px; border-radius: 12px; background: var(--mk-surface-3, var(--mk-surface)); overflow: hidden; font-weight: 600; font-size: 14px; }
|
|
4
|
+
.mk-scroll-preview-dock__image img, .mk-scroll-preview-dock__thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
5
|
+
.mk-scroll-preview-dock__preview strong { display: block; font-size: 13px; font-weight: 600; line-height: 1.4; }
|
|
6
|
+
.mk-scroll-preview-dock__description { display: block; color: var(--mk-muted); font-size: 11px; line-height: 1.5; }
|
|
7
|
+
.mk-scroll-preview-dock__nav { position: relative; box-sizing: border-box; display: flex; align-items: stretch; justify-content: space-between; gap: 4px; width: 100%; padding: 6px; border-radius: 32px; background: var(--mk-surface); }
|
|
8
|
+
.mk-scroll-preview-dock__pill { position: absolute; top: 0; left: 0; border-radius: 24px; background: var(--mk-surface-3, var(--mk-surface-2)); pointer-events: none; box-shadow: 0 1px 0 var(--mk-outline-soft) inset; }
|
|
9
|
+
.mk-scroll-preview-dock__item { position: relative; z-index: 1; display: flex; flex: 1 1 0; align-items: center; flex-direction: column; justify-content: center; gap: 6px; min-width: 0; min-height: 68px; padding: 6px; border-radius: 24px; text-align: center; color: var(--mk-muted); text-decoration: none; font-size: 10px; line-height: 1.3; -webkit-tap-highlight-color: transparent; transition: color .2s; }
|
|
10
|
+
.mk-scroll-preview-dock__thumb { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 12px; background: var(--mk-surface-2); overflow: hidden; color: var(--mk-text); font-size: 10px; font-weight: 600; transition: transform .4s var(--mk-ease-swatch), border-radius .4s var(--mk-ease-swatch); }
|
|
11
|
+
.mk-scroll-preview-dock__item[aria-current] { color: var(--mk-text); }
|
|
12
|
+
.mk-scroll-preview-dock__item[data-mk-preview=true] .mk-scroll-preview-dock__thumb { transform: translateY(-3px) rotate(-4deg) scale(1.08); border-radius: 8px; }
|
|
13
|
+
.mk-scroll-preview-dock__item:focus-visible { outline: 2px solid var(--mk-accent); outline-offset: 2px; }
|
|
14
|
+
.mk-scroll-preview-dock__item[aria-disabled=true] { opacity: .4; cursor: not-allowed; }
|
|
15
|
+
.mk-scroll-preview-dock [hidden] { display: none !important; }
|
|
16
|
+
.mk-scroll-preview-dock__empty { margin: 0; padding: 20px; text-align: center; background: var(--mk-surface); border-radius: 24px; font-size: 12px; color: var(--mk-muted); }
|
|
17
|
+
@media (prefers-reduced-motion: reduce) { .mk-scroll-preview-dock * { transition: none !important; } }
|
|
@@ -1,34 +1,48 @@
|
|
|
1
1
|
# select
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
Origen: archivo del kit y parámetros (tiempos, curvas).
|
|
3
|
+
Una elección de una lista que se abre como el `menu` del kit (crece desde su botón y las opciones entran escalonadas) en lugar de la lista del sistema. Mejora un `<select>` escrito a mano, o crea uno desde `options`, y lo mantiene sincronizado y oculto: los formularios lo envían y sus escuchas de `change` siguen funcionando. El valor elegido entra con un blur breve y el chevron gira.
|
|
6
4
|
|
|
7
5
|
## HTML / Astro
|
|
8
6
|
|
|
9
7
|
```html
|
|
10
|
-
<mk-select
|
|
8
|
+
<mk-select class="mk-select" label="Responsable">
|
|
9
|
+
<select name="owner">
|
|
10
|
+
<option value="">Elige a alguien</option>
|
|
11
|
+
<optgroup label="Diseño"><option value="ana">Ana Gómez</option></optgroup>
|
|
12
|
+
<optgroup label="Código"><option value="leo">Leo Ruiz</option></optgroup>
|
|
13
|
+
</select>
|
|
14
|
+
</mk-select>
|
|
11
15
|
<script>
|
|
12
16
|
import { defineElements } from '@yewdiel21/motion-kit/elements';
|
|
13
17
|
defineElements(['mk-select']);
|
|
14
18
|
</script>
|
|
15
19
|
```
|
|
16
20
|
|
|
21
|
+
Dentro de un `.mk-field` el botón toma la altura, el radio y el relleno de los demás campos. Para cambiar el valor desde fuera usa el atributo `value` (o `setValue` en la instancia); el `<select>` y el botón se actualizan juntos.
|
|
22
|
+
|
|
17
23
|
## React
|
|
18
24
|
|
|
19
25
|
```tsx
|
|
20
|
-
<Select
|
|
26
|
+
<Select label="Prioridad" name="priority" value={priority} onChange={setPriority}
|
|
27
|
+
options={[{ value: 'low', label: 'Baja' }, { value: 'normal', label: 'Normal' }, { value: 'high', label: 'Alta' }]} />
|
|
21
28
|
```
|
|
22
29
|
|
|
30
|
+
`options` acepta `{ value, label, disabled?, group? }`; `group` crea encabezados como `<optgroup>`.
|
|
31
|
+
|
|
23
32
|
## Opciones
|
|
24
33
|
|
|
25
34
|
| Opción | Atributo | Por defecto | Qué hace |
|
|
26
35
|
|---|---|---|---|
|
|
36
|
+
| `options` | — | las del `<select>` | Opciones; sin ellas se leen del `<select>` interior. |
|
|
37
|
+
| `value` | `value` | el del `<select>` | Valor elegido. |
|
|
38
|
+
| `name` | `name` | — | Nombre del `<select>` que crea (uno escrito conserva el suyo). |
|
|
39
|
+
| `label` | `label` | `'Opciones'` | Nombre accesible; el botón se lee «label: elegido». |
|
|
40
|
+
| `placeholder` | `placeholder` | texto de la opción vacía o `'Elige…'` | Texto mientras no hay elección. |
|
|
41
|
+
| `disabled` | `disabled` | `false` | Desactiva el botón. |
|
|
42
|
+
| `onChange(value)` | evento `change` del `<select>` / `mk:change` | — | Avisa al elegir. |
|
|
27
43
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
`--mk-select-radius`
|
|
44
|
+
Instancia: `setValue(value, notify?)`, `setOptions(options)`, `refresh()`, `open()`, `close()`, `value`, `destroy()`.
|
|
31
45
|
|
|
32
46
|
## Accesibilidad
|
|
33
47
|
|
|
34
|
-
|
|
48
|
+
El botón tiene `aria-haspopup="menu"` y `aria-expanded`; el menú usa `menuitemradio` con `aria-checked` en la opción elegida. ↓ ↑ Intro y Espacio abren el menú; con el botón cerrado, escribir una letra elige la siguiente opción que empieza por ella. Funciona dentro de diálogos, paneles y popovers sin cerrarlos. Con movimiento reducido el menú aparece sin escalonado y el valor cambia sin blur.
|
|
@@ -133,3 +133,19 @@ mk-select {
|
|
|
133
133
|
transition: none;
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
+
|
|
137
|
+
/* Inside a kit field the trigger takes the field's height, radius, fill and type. */
|
|
138
|
+
.mk-field :is(.mk-select, mk-select) {
|
|
139
|
+
display: flex;
|
|
140
|
+
width: 100%;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.mk-field [data-mk-select-trigger] {
|
|
144
|
+
height: auto;
|
|
145
|
+
min-height: 56px;
|
|
146
|
+
padding: 0 14px 0 16px;
|
|
147
|
+
border-radius: var(--mk-field-radius);
|
|
148
|
+
background: var(--mk-field-bg);
|
|
149
|
+
font-family: var(--md-sys-typescale-body-large-font);
|
|
150
|
+
font-size: var(--md-sys-typescale-body-large-size);
|
|
151
|
+
}
|
|
@@ -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);
|