@yewdiel21/motion-kit 0.16.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 (60) hide show
  1. package/README.md +21 -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-SDQ776LG.js → chunk-7IQTMF7Y.js} +1414 -38
  6. package/dist/chunk-7IQTMF7Y.js.map +1 -0
  7. package/dist/{chunk-NFZIX3IA.js → chunk-BZLMCHUD.js} +2 -2
  8. package/dist/{chunk-RBEKR5UZ.js → chunk-VJFPTLAK.js} +2 -2
  9. package/dist/css/components/drilldown-menu.css +130 -0
  10. package/dist/css/components/signature-pad.css +104 -0
  11. package/dist/css/components/voice-message-bubble.css +50 -0
  12. package/dist/css/components/waveform-player.css +36 -0
  13. package/dist/css/components/wheel-picker.css +17 -0
  14. package/dist/elements/index.d.ts +90 -4
  15. package/dist/elements/index.js +281 -4
  16. package/dist/elements/index.js.map +1 -1
  17. package/dist/{reveal-DRpNO4KP.d.ts → engine-Bn6hC87X.d.ts} +194 -1
  18. package/dist/index.css +305 -0
  19. package/dist/index.d.ts +3 -3
  20. package/dist/index.js +13 -3
  21. package/dist/index.js.map +1 -1
  22. package/dist/react/index.d.ts +45 -7
  23. package/dist/react/index.js +227 -3
  24. package/dist/react/index.js.map +1 -1
  25. package/dist/{reduced-DQwVXQYb.d.ts → reduced-YS6TZjpa.d.ts} +1 -1
  26. package/dist/{store-Da3zSh8Z.d.ts → store-CZ6vdtIE.d.ts} +1 -1
  27. package/package.json +110 -110
  28. package/registry.json +64 -0
  29. package/src/auto/auto-init.ts +308 -293
  30. package/src/components/drilldown-menu/README.md +163 -0
  31. package/src/components/drilldown-menu/drilldown-menu.css +129 -0
  32. package/src/components/drilldown-menu/element.ts +59 -0
  33. package/src/components/drilldown-menu/engine.ts +338 -0
  34. package/src/components/drilldown-menu/react.tsx +76 -0
  35. package/src/components/signature-pad/README.md +97 -0
  36. package/src/components/signature-pad/element.ts +57 -0
  37. package/src/components/signature-pad/engine.ts +437 -0
  38. package/src/components/signature-pad/react.tsx +56 -0
  39. package/src/components/signature-pad/signature-pad.css +103 -0
  40. package/src/components/voice-message-bubble/README.md +31 -0
  41. package/src/components/voice-message-bubble/element.ts +20 -0
  42. package/src/components/voice-message-bubble/engine.ts +68 -0
  43. package/src/components/voice-message-bubble/react.tsx +27 -0
  44. package/src/components/voice-message-bubble/voice-message-bubble.css +12 -0
  45. package/src/components/waveform-player/README.md +30 -0
  46. package/src/components/waveform-player/element.ts +21 -0
  47. package/src/components/waveform-player/engine.ts +261 -0
  48. package/src/components/waveform-player/react.tsx +27 -0
  49. package/src/components/waveform-player/waveform-player.css +35 -0
  50. package/src/components/wheel-picker/README.md +33 -0
  51. package/src/components/wheel-picker/element.ts +18 -0
  52. package/src/components/wheel-picker/engine.ts +210 -0
  53. package/src/components/wheel-picker/react.tsx +32 -0
  54. package/src/components/wheel-picker/wheel-picker.css +16 -0
  55. package/src/elements/index.ts +273 -258
  56. package/src/index.ts +295 -290
  57. package/src/react/index.ts +176 -171
  58. package/dist/chunk-SDQ776LG.js.map +0 -1
  59. /package/dist/{chunk-NFZIX3IA.js.map → chunk-BZLMCHUD.js.map} +0 -0
  60. /package/dist/{chunk-RBEKR5UZ.js.map → chunk-VJFPTLAK.js.map} +0 -0
@@ -0,0 +1,338 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { inert, type Instance } from '../../core/types';
5
+
6
+ export interface DrilldownMenuItem {
7
+ /** Unique among siblings; IDs form the navigation path. */
8
+ id: string;
9
+ label: string;
10
+ description?: string;
11
+ disabled?: boolean;
12
+ /** Present (even an empty array) for a level; absent for an action. */
13
+ children?: DrilldownMenuItem[];
14
+ }
15
+
16
+ export interface DrilldownMenuOptions {
17
+ items?: DrilldownMenuItem[];
18
+ /** IDs of branches from the root to the initial level. */
19
+ path?: string[];
20
+ /** Root heading and accessible name (default 'Actions'). */
21
+ label?: string;
22
+ backLabel?: string;
23
+ emptyText?: string;
24
+ /** An action's complete path, including the action's ID. */
25
+ onSelect?: (item: DrilldownMenuItem, path: string[]) => void;
26
+ /** Navigation only; the path contains branch IDs. */
27
+ onPathChange?: (path: string[]) => void;
28
+ }
29
+
30
+ export interface DrilldownMenuInstance extends Instance {
31
+ /** Open a branch in the current level; false for missing, disabled or leaf items. */
32
+ open(id: string): boolean;
33
+ back(): boolean;
34
+ root(): void;
35
+ reset(): void;
36
+ /** Invalid, disabled or non-branch IDs truncate the requested path safely. */
37
+ setPath(path: string[], notify?: boolean): void;
38
+ setItems(items: DrilldownMenuItem[]): void;
39
+ update(options: Partial<DrilldownMenuOptions>): void;
40
+ focusItem(id: string): void;
41
+ readonly path: string[];
42
+ /** Items in the current level. */
43
+ readonly items: DrilldownMenuItem[];
44
+ readonly element: HTMLElement;
45
+ }
46
+
47
+ const CHEVRON = '<svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 6 6 6-6 6"/></svg>';
48
+ let sequence = 0;
49
+ const samePath = (a: string[], b: string[]) => a.length === b.length && a.every((id, i) => id === b[i]);
50
+ const branch = (item: DrilldownMenuItem) => Array.isArray(item.children);
51
+ const copyItems = (items: DrilldownMenuItem[]): DrilldownMenuItem[] => items.map((item) => ({ ...item, ...(branch(item) ? { children: copyItems(item.children!) } : {}) }));
52
+
53
+ /** A single live level: directional slide + blur, roving menu focus, and reversible DOM ownership. */
54
+ export function createDrilldownMenu(root: HTMLElement | null, options: DrilldownMenuOptions = {}): DrilldownMenuInstance {
55
+ if (!root) return inert({ open: () => false, back: () => false, root() {}, reset() {}, setPath() {}, setItems() {}, update() {}, focusItem() {}, path: [], items: [], element: null as unknown as HTMLElement });
56
+ registerMotion();
57
+ const scope = createScope();
58
+ const uid = `mk-drilldown-${++sequence}`;
59
+ const authored = [...root.childNodes];
60
+ // Keep the authored nodes themselves, including any listeners, for destroy().
61
+ const holder = document.createDocumentFragment();
62
+ holder.append(...authored);
63
+ let config = { ...options };
64
+ let data = copyItems(options.items ?? []);
65
+ let path: string[] = [];
66
+ let focused: string | null = null;
67
+ const remembered = new Map<string, string>();
68
+ let animation: gsap.core.Timeline | null = null;
69
+
70
+ const header = document.createElement('div');
71
+ header.dataset.mkDrilldownHeader = '';
72
+ const backButton = document.createElement('button');
73
+ backButton.type = 'button';
74
+ backButton.dataset.mkDrilldownBack = '';
75
+ backButton.innerHTML = CHEVRON;
76
+ const heading = document.createElement('span');
77
+ heading.id = `${uid}-heading`;
78
+ heading.dataset.mkDrilldownHeading = '';
79
+ header.append(backButton, heading);
80
+ const breadcrumb = document.createElement('nav');
81
+ breadcrumb.dataset.mkDrilldownBreadcrumb = '';
82
+ const crumbs = document.createElement('ol');
83
+ breadcrumb.append(crumbs);
84
+ const viewport = document.createElement('div');
85
+ viewport.dataset.mkDrilldownViewport = '';
86
+ const menu = document.createElement('div');
87
+ menu.dataset.mkDrilldownLevel = '';
88
+ menu.id = `${uid}-level`;
89
+ menu.setAttribute('role', 'menu');
90
+ menu.setAttribute('aria-labelledby', heading.id);
91
+ const empty = document.createElement('p');
92
+ empty.dataset.mkDrilldownEmpty = '';
93
+ empty.setAttribute('role', 'status');
94
+ viewport.append(menu, empty);
95
+ backButton.setAttribute('aria-controls', menu.id);
96
+ root.append(header, breadcrumb, viewport);
97
+ scope.attr(root, 'data-mk-ready', '');
98
+
99
+ const levelAt = (requested: string[]) => {
100
+ const valid: string[] = [];
101
+ const ancestors: DrilldownMenuItem[] = [];
102
+ let items = data;
103
+ for (const id of requested) {
104
+ const item = items.find((entry) => entry.id === id);
105
+ if (!item || item.disabled || !branch(item)) break;
106
+ valid.push(id);
107
+ ancestors.push(item);
108
+ items = item.children!;
109
+ }
110
+ return { valid, ancestors, items };
111
+ };
112
+ const level = () => levelAt(path);
113
+ const enabled = () => [...menu.querySelectorAll<HTMLButtonElement>('[data-mk-drilldown-item]')].filter((item) => !item.disabled);
114
+ const buttonOf = (id: string) => enabled().find((item) => item.dataset.mkDrilldownItem === id);
115
+ const cancelMotion = () => {
116
+ animation?.kill();
117
+ animation = null;
118
+ gsap.set(menu, { clearProps: 'transform,opacity,filter' });
119
+ gsap.set(viewport, { clearProps: 'height' });
120
+ };
121
+ const roving = (id: string | null, focus = false) => {
122
+ const items = enabled();
123
+ const target = items.find((item) => item.dataset.mkDrilldownItem === id) ?? items[0];
124
+ focused = target?.dataset.mkDrilldownItem ?? null;
125
+ for (const item of menu.querySelectorAll<HTMLButtonElement>('[data-mk-drilldown-item]')) item.tabIndex = item === target ? 0 : -1;
126
+ // Empty/all-disabled levels remain a keyboard stop so Left/Escape can return.
127
+ menu.tabIndex = target ? -1 : 0;
128
+ if (focused) remembered.set(JSON.stringify(path), focused);
129
+ if (focus) (target ?? menu).focus({ preventScroll: true });
130
+ };
131
+ const notifyPath = () => {
132
+ const next = [...path];
133
+ root.dispatchEvent(new CustomEvent('mk:path-change', { bubbles: true, detail: { path: [...next] } }));
134
+ config.onPathChange?.(next);
135
+ };
136
+
137
+ const render = (direction: number, focus: boolean, preferred?: string | null) => {
138
+ const oldHeight = viewport.getBoundingClientRect().height;
139
+ cancelMotion();
140
+ const current = level();
141
+ const label = config.label ?? 'Actions';
142
+ const title = current.ancestors.at(-1)?.label ?? label;
143
+ heading.textContent = title;
144
+ backButton.hidden = path.length === 0;
145
+ backButton.setAttribute('aria-label', config.backLabel ?? 'Back');
146
+ breadcrumb.setAttribute('aria-label', `${label} path`);
147
+ const labels = [label, ...current.ancestors.map((item) => item.label)];
148
+ crumbs.replaceChildren(...labels.map((text, index) => {
149
+ const li = document.createElement('li');
150
+ if (index === labels.length - 1) {
151
+ const span = document.createElement('span');
152
+ span.setAttribute('aria-current', 'location');
153
+ span.textContent = text;
154
+ li.append(span);
155
+ } else {
156
+ const button = document.createElement('button');
157
+ button.type = 'button';
158
+ button.dataset.mkDrilldownCrumb = String(index);
159
+ button.textContent = text;
160
+ button.setAttribute('aria-controls', menu.id);
161
+ li.append(button);
162
+ }
163
+ return li;
164
+ }));
165
+ menu.replaceChildren(...current.items.map((item, index) => {
166
+ const button = document.createElement('button');
167
+ button.type = 'button';
168
+ button.dataset.mkDrilldownItem = item.id;
169
+ button.setAttribute('role', 'menuitem');
170
+ button.disabled = Boolean(item.disabled);
171
+ if (item.disabled) button.setAttribute('aria-disabled', 'true');
172
+ const text = document.createElement('span');
173
+ text.dataset.mkDrilldownText = '';
174
+ const name = document.createElement('span');
175
+ name.id = `${uid}-item-${index}`;
176
+ name.dataset.mkDrilldownLabel = '';
177
+ name.textContent = item.label;
178
+ button.setAttribute('aria-labelledby', name.id);
179
+ text.append(name);
180
+ if (item.description) {
181
+ const description = document.createElement('span');
182
+ description.id = `${uid}-description-${index}`;
183
+ description.dataset.mkDrilldownDescription = '';
184
+ description.textContent = item.description;
185
+ button.setAttribute('aria-describedby', description.id);
186
+ text.append(description);
187
+ }
188
+ button.append(text);
189
+ if (branch(item)) {
190
+ button.setAttribute('aria-haspopup', 'menu');
191
+ const arrow = document.createElement('span');
192
+ arrow.dataset.mkDrilldownArrow = '';
193
+ arrow.innerHTML = CHEVRON;
194
+ button.append(arrow);
195
+ }
196
+ return button;
197
+ }));
198
+ empty.textContent = config.emptyText ?? 'No actions available';
199
+ empty.hidden = current.items.length !== 0;
200
+ roving(preferred ?? remembered.get(JSON.stringify(path)) ?? null);
201
+ if (direction && !reduced()) {
202
+ // There is only one live menu. Replacing its content before animating avoids
203
+ // duplicated menuitems and allows rapid navigation without stale callbacks.
204
+ animation = gsap.timeline({ onComplete: () => { animation = null; } })
205
+ .fromTo(menu, { x: 28 * direction, opacity: 0, filter: 'blur(6px)' }, { x: 0, opacity: 1, filter: 'blur(0px)', duration: 0.36, ease: EASE.signature, clearProps: 'transform,opacity,filter' });
206
+ const nextHeight = viewport.getBoundingClientRect().height;
207
+ if (oldHeight > 0 && nextHeight > 0 && oldHeight !== nextHeight) {
208
+ animation.fromTo(viewport, { height: oldHeight }, { height: nextHeight, duration: 0.36, ease: EASE.signature, clearProps: 'height' }, 0);
209
+ }
210
+ }
211
+ // Transform measurement may temporarily detach a zero-size element. Restore
212
+ // focus after GSAP has measured, including in hidden surfaces and jsdom.
213
+ if (focus) roving(focused, true);
214
+ };
215
+
216
+ const navigate = (requested: string[], notify: boolean, focus: boolean, preferred?: string | null) => {
217
+ if (scope.disposed) return false;
218
+ const next = levelAt(requested).valid;
219
+ if (samePath(path, next)) return false;
220
+ const previous = path;
221
+ if (focused) remembered.set(JSON.stringify(previous), focused);
222
+ const direction = next.length > path.length ? 1 : -1;
223
+ path = next;
224
+ render(direction, focus, preferred);
225
+ if (notify) notifyPath();
226
+ return true;
227
+ };
228
+ const open = (id: string) => {
229
+ if (scope.disposed) return false;
230
+ const item = level().items.find((entry) => entry.id === id);
231
+ if (!item || item.disabled || !branch(item)) return false;
232
+ return navigate([...path, id], true, true);
233
+ };
234
+ const back = () => path.length ? navigate(path.slice(0, -1), true, true, path.at(-1)) : false;
235
+ const reset = () => navigate([], true, root.contains(document.activeElement), path[0]);
236
+ const activate = (id: string) => {
237
+ const item = level().items.find((entry) => entry.id === id);
238
+ if (!item || item.disabled) return;
239
+ if (branch(item)) return void open(id);
240
+ const selected = copyItems([item])[0]!;
241
+ const selectedPath = [...path, item.id];
242
+ root.dispatchEvent(new CustomEvent('mk:select', { bubbles: true, detail: { item: selected, path: [...selectedPath] } }));
243
+ config.onSelect?.(copyItems([item])[0]!, [...selectedPath]);
244
+ };
245
+
246
+ scope.on(menu, 'click', (event) => {
247
+ const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-item]');
248
+ if (!button || !menu.contains(button) || button.disabled) return;
249
+ roving(button.dataset.mkDrilldownItem!, false);
250
+ activate(button.dataset.mkDrilldownItem!);
251
+ });
252
+ scope.on(menu, 'focusin', (event) => {
253
+ const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-item]');
254
+ if (button && !button.disabled) roving(button.dataset.mkDrilldownItem!, false);
255
+ });
256
+ scope.on(root, 'keydown', (event) => {
257
+ const key = (event as KeyboardEvent).key;
258
+ if ((key === 'ArrowLeft' || key === 'Escape') && path.length) {
259
+ event.preventDefault();
260
+ event.stopPropagation();
261
+ back();
262
+ return;
263
+ }
264
+ if (!menu.contains(event.target as Node)) return;
265
+ const items = enabled();
266
+ const at = items.indexOf(document.activeElement as HTMLButtonElement);
267
+ const moves: Record<string, number> = { ArrowDown: (at + 1) % items.length, ArrowUp: at < 0 ? items.length - 1 : (at - 1 + items.length) % items.length, Home: 0, End: items.length - 1 };
268
+ if (key in moves && items.length) {
269
+ event.preventDefault();
270
+ roving(items[moves[key]]!.dataset.mkDrilldownItem!, true);
271
+ return;
272
+ }
273
+ const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-item]');
274
+ if (!button || button.disabled || !menu.contains(button)) return;
275
+ if (key === 'Enter' || key === ' ' || key === 'ArrowRight') {
276
+ const item = level().items.find((entry) => entry.id === button.dataset.mkDrilldownItem);
277
+ if (key === 'ArrowRight' && (!item || !branch(item))) return;
278
+ event.preventDefault();
279
+ activate(button.dataset.mkDrilldownItem!);
280
+ }
281
+ });
282
+ scope.on(backButton, 'click', () => back());
283
+ scope.on(breadcrumb, 'click', (event) => {
284
+ const button = (event.target as Element | null)?.closest<HTMLButtonElement>('[data-mk-drilldown-crumb]');
285
+ if (!button || !breadcrumb.contains(button)) return;
286
+ const depth = Number(button.dataset.mkDrilldownCrumb);
287
+ navigate(path.slice(0, depth), true, true, path[depth]);
288
+ });
289
+ scope.add(() => {
290
+ cancelMotion();
291
+ remembered.clear();
292
+ header.remove();
293
+ breadcrumb.remove();
294
+ viewport.remove();
295
+ root.append(holder);
296
+ });
297
+ path = levelAt(options.path ?? []).valid;
298
+ render(0, false);
299
+
300
+ const update = (next: Partial<DrilldownMenuOptions>) => {
301
+ if (scope.disposed) return;
302
+ const active = document.activeElement as HTMLElement | null;
303
+ const hadFocus = root.contains(active);
304
+ const backFocused = active === backButton;
305
+ const crumbFocused = active?.dataset.mkDrilldownCrumb;
306
+ const previousPath = path;
307
+ const previousFocus = focused;
308
+ config = { ...config, ...next };
309
+ if (next.items !== undefined) data = copyItems(next.items);
310
+ path = levelAt(next.path ?? path).valid;
311
+ const changed = !samePath(previousPath, path);
312
+ const preferred = changed ? previousPath[path.length] : previousFocus;
313
+ render(0, hadFocus, preferred);
314
+ if (hadFocus && !changed) {
315
+ if (backFocused && !backButton.hidden) backButton.focus({ preventScroll: true });
316
+ else if (crumbFocused !== undefined) {
317
+ [...crumbs.querySelectorAll<HTMLButtonElement>('[data-mk-drilldown-crumb]')]
318
+ .find((button) => button.dataset.mkDrilldownCrumb === crumbFocused)?.focus({ preventScroll: true });
319
+ }
320
+ }
321
+ // Updating data may invalidate a controlled path; report the repaired path.
322
+ if (changed && next.path === undefined) notifyPath();
323
+ };
324
+ return {
325
+ open,
326
+ back,
327
+ root: reset,
328
+ reset,
329
+ setPath: (next, notify = false) => { navigate(next, notify, root.contains(document.activeElement)); },
330
+ setItems: (items) => update({ items }),
331
+ update,
332
+ focusItem(id) { if (!scope.disposed && buttonOf(id)) roving(id, true); },
333
+ get path() { return [...path]; },
334
+ get items() { return copyItems(level().items); },
335
+ element: root,
336
+ destroy: () => scope.dispose(),
337
+ };
338
+ }
@@ -0,0 +1,76 @@
1
+ 'use client';
2
+
3
+ import { forwardRef, useImperativeHandle, useReducer, useRef, type HTMLAttributes } from 'react';
4
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
5
+ import { createDrilldownMenu, type DrilldownMenuInstance, type DrilldownMenuItem, type DrilldownMenuOptions } from './engine';
6
+
7
+ export interface DrilldownMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'dangerouslySetInnerHTML' | 'onSelect'>, Omit<DrilldownMenuOptions, 'items' | 'path'> {
8
+ items: DrilldownMenuItem[];
9
+ /** Controlled branch path; pair with onPathChange. */
10
+ path?: string[];
11
+ /** Initial branch path when uncontrolled. */
12
+ defaultPath?: string[];
13
+ }
14
+
15
+ export type DrilldownMenuHandle = Pick<DrilldownMenuInstance, 'open' | 'back' | 'root' | 'reset' | 'setPath' | 'focusItem'>;
16
+
17
+ /** Data-driven hierarchical actions with controlled or internal navigation. */
18
+ export const DrilldownMenu = forwardRef<DrilldownMenuHandle, DrilldownMenuProps>(function DrilldownMenu(
19
+ { items, path, defaultPath, label, backLabel, emptyText, onSelect, onPathChange, className, ...props },
20
+ handle,
21
+ ) {
22
+ const { children: _children, dangerouslySetInnerHTML: _html, ...rootProps } = props as HTMLAttributes<HTMLDivElement>;
23
+ const ref = useRef<HTMLDivElement>(null);
24
+ const instance = useRef<DrilldownMenuInstance | null>(null);
25
+ const select = useLatest(onSelect);
26
+ const changed = useLatest(onPathChange);
27
+ const controlled = useLatest(path);
28
+ const [, refresh] = useReducer((value: number) => value + 1, 0);
29
+ useImperativeHandle(handle, () => ({
30
+ open: (id) => instance.current?.open(id) ?? false,
31
+ back: () => instance.current?.back() ?? false,
32
+ root: () => instance.current?.root(),
33
+ reset: () => instance.current?.reset(),
34
+ setPath: (next, notify) => {
35
+ instance.current?.setPath(next, notify);
36
+ // Silent engine synchronization does not invoke onPathChange. Controlled
37
+ // refs must still reconcile with the parent's current path on a render.
38
+ if (controlled.current !== undefined) refresh();
39
+ },
40
+ focusItem: (id) => instance.current?.focusItem(id),
41
+ }), []);
42
+ const itemsKey = JSON.stringify(items);
43
+ useIsomorphicLayoutEffect(() => {
44
+ const created = createDrilldownMenu(ref.current, {
45
+ items,
46
+ path: path ?? defaultPath,
47
+ label,
48
+ backLabel,
49
+ emptyText,
50
+ onSelect: (item, selectedPath) => select.current?.(item, selectedPath),
51
+ onPathChange: (next) => {
52
+ changed.current?.(next);
53
+ // Also render if a controlled parent declines the navigation request.
54
+ if (controlled.current !== undefined) refresh();
55
+ },
56
+ });
57
+ instance.current = created;
58
+ return () => {
59
+ created.destroy();
60
+ instance.current = null;
61
+ };
62
+ // The engine owns its DOM for this mount; subsequent props update it below.
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ }, []);
65
+ useIsomorphicLayoutEffect(() => {
66
+ // Controlled data and its path are one external model. Applying the path in
67
+ // this update avoids repairing a stale level against replacement items.
68
+ instance.current?.update({ items, label, backLabel, emptyText, ...(path !== undefined ? { path } : {}) });
69
+ // JSON keys also catch edited data without forcing redraws on equal arrays.
70
+ // eslint-disable-next-line react-hooks/exhaustive-deps
71
+ }, [itemsKey, label, backLabel, emptyText]);
72
+ useIsomorphicLayoutEffect(() => {
73
+ if (path !== undefined) instance.current?.setPath(path);
74
+ });
75
+ return <div {...rootProps} ref={ref} className={['mk-drilldown-menu', className].filter(Boolean).join(' ')} />;
76
+ });
@@ -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);