@yewdiel21/motion-kit 0.14.0 → 0.15.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 (154) hide show
  1. package/README.md +174 -155
  2. package/bin/motion-kit.mjs +3 -3
  3. package/dist/auto/index.d.ts +6 -3
  4. package/dist/auto/index.js +8 -2
  5. package/dist/auto/index.js.map +1 -1
  6. package/dist/{chunk-EY4BJKC4.js → chunk-6IRJYXJU.js} +2 -2
  7. package/dist/{chunk-CU6SXW3E.js → chunk-A6QQCF6V.js} +1309 -310
  8. package/dist/chunk-A6QQCF6V.js.map +1 -0
  9. package/dist/chunk-JXRS3OEA.js +3638 -0
  10. package/dist/chunk-JXRS3OEA.js.map +1 -0
  11. package/dist/css/components/add-to-cart.css +72 -0
  12. package/dist/css/components/bidirectional-slider.css +168 -0
  13. package/dist/css/components/data-table.css +121 -26
  14. package/dist/css/components/filter-builder.css +10 -15
  15. package/dist/css/components/heatmap-chart.css +391 -0
  16. package/dist/css/components/hover-card.css +8 -2
  17. package/dist/css/components/image-preview.css +90 -2
  18. package/dist/css/components/json-viewer.css +33 -1
  19. package/dist/css/components/nav.css +102 -102
  20. package/dist/css/components/orbit-menu.css +130 -0
  21. package/dist/css/components/pagination.css +10 -5
  22. package/dist/css/components/pie-chart.css +390 -0
  23. package/dist/css/components/pill-button.css +135 -0
  24. package/dist/css/components/radar-chart.css +370 -0
  25. package/dist/css/components/radial-chart.css +398 -0
  26. package/dist/css/components/sankey-chart.css +358 -0
  27. package/dist/css/components/scroll-island.css +111 -0
  28. package/dist/css/components/select.css +136 -0
  29. package/dist/css/components/toolbar.css +122 -0
  30. package/dist/css/components/tree-view.css +79 -0
  31. package/dist/css/components/voice-dots.css +70 -0
  32. package/dist/elements/index.d.ts +180 -7
  33. package/dist/elements/index.js +287 -6
  34. package/dist/elements/index.js.map +1 -1
  35. package/dist/{engine-Cn6IhE3z.d.ts → engine-5zRIssTO.d.ts} +11 -1
  36. package/dist/index.css +1832 -168
  37. package/dist/index.d.ts +4 -4
  38. package/dist/index.js +33 -3
  39. package/dist/index.js.map +1 -1
  40. package/dist/react/index.d.ts +117 -11
  41. package/dist/react/index.js +314 -13
  42. package/dist/react/index.js.map +1 -1
  43. package/dist/{reduced-BBUusld6.d.ts → reduced-6X_bns60.d.ts} +1 -1
  44. package/dist/{reveal-DEvp6U6T.d.ts → reveal-BfyKKsw4.d.ts} +97 -4
  45. package/dist/store-CA2Qcun_.d.ts +453 -0
  46. package/package.json +9 -16
  47. package/registry.json +1096 -948
  48. package/src/auto/auto-init.ts +6 -0
  49. package/src/components/add-to-cart/README.md +34 -0
  50. package/src/components/add-to-cart/add-to-cart.css +71 -0
  51. package/src/components/add-to-cart/element.ts +20 -0
  52. package/src/components/add-to-cart/engine.ts +206 -0
  53. package/src/components/add-to-cart/react.tsx +29 -0
  54. package/src/components/bidirectional-slider/README.md +46 -0
  55. package/src/components/bidirectional-slider/bidirectional-slider.css +167 -0
  56. package/src/components/bidirectional-slider/element.ts +42 -0
  57. package/src/components/bidirectional-slider/engine.ts +289 -0
  58. package/src/components/bidirectional-slider/react.tsx +45 -0
  59. package/src/components/chart/README.md +10 -0
  60. package/src/components/chart/element.ts +2 -0
  61. package/src/components/chart/engine.ts +50 -23
  62. package/src/components/chart/parts.ts +237 -0
  63. package/src/components/chart/react.tsx +2 -2
  64. package/src/components/chart/use-chart.ts +55 -0
  65. package/src/components/data-table/README.md +3 -3
  66. package/src/components/data-table/data-table.css +121 -26
  67. package/src/components/data-table/engine.ts +81 -29
  68. package/src/components/editable-table/engine.ts +67 -11
  69. package/src/components/filter-builder/engine.ts +33 -2
  70. package/src/components/filter-builder/filter-builder.css +10 -15
  71. package/src/components/heatmap-chart/README.md +48 -0
  72. package/src/components/heatmap-chart/element.ts +21 -0
  73. package/src/components/heatmap-chart/engine.ts +281 -0
  74. package/src/components/heatmap-chart/heatmap-chart.css +93 -0
  75. package/src/components/heatmap-chart/react.tsx +14 -0
  76. package/src/components/hover-card/engine.ts +30 -1
  77. package/src/components/hover-card/hover-card.css +8 -2
  78. package/src/components/image-preview/element.ts +1 -1
  79. package/src/components/image-preview/engine.ts +95 -15
  80. package/src/components/image-preview/image-preview.css +90 -2
  81. package/src/components/image-preview/react.tsx +3 -3
  82. package/src/components/json-viewer/engine.ts +11 -2
  83. package/src/components/json-viewer/json-viewer.css +33 -1
  84. package/src/components/nav/README.md +12 -10
  85. package/src/components/nav/element.ts +5 -5
  86. package/src/components/nav/engine.ts +21 -14
  87. package/src/components/nav/nav.css +102 -102
  88. package/src/components/nav/react.tsx +4 -4
  89. package/src/components/orbit-menu/README.md +46 -0
  90. package/src/components/orbit-menu/element.ts +27 -0
  91. package/src/components/orbit-menu/engine.ts +179 -0
  92. package/src/components/orbit-menu/orbit-menu.css +129 -0
  93. package/src/components/orbit-menu/react.tsx +44 -0
  94. package/src/components/pagination/README.md +1 -1
  95. package/src/components/pagination/engine.ts +50 -4
  96. package/src/components/pagination/pagination.css +10 -5
  97. package/src/components/pie-chart/README.md +48 -0
  98. package/src/components/pie-chart/element.ts +30 -0
  99. package/src/components/pie-chart/engine.ts +283 -0
  100. package/src/components/pie-chart/pie-chart.css +92 -0
  101. package/src/components/pie-chart/react.tsx +14 -0
  102. package/src/components/pill-button/README.md +46 -0
  103. package/src/components/pill-button/element.ts +16 -0
  104. package/src/components/pill-button/engine.ts +77 -0
  105. package/src/components/pill-button/pill-button.css +134 -0
  106. package/src/components/pill-button/react.tsx +32 -0
  107. package/src/components/radar-chart/README.md +48 -0
  108. package/src/components/radar-chart/element.ts +30 -0
  109. package/src/components/radar-chart/engine.ts +327 -0
  110. package/src/components/radar-chart/radar-chart.css +72 -0
  111. package/src/components/radar-chart/react.tsx +14 -0
  112. package/src/components/radial-chart/README.md +48 -0
  113. package/src/components/radial-chart/element.ts +30 -0
  114. package/src/components/radial-chart/engine.ts +290 -0
  115. package/src/components/radial-chart/radial-chart.css +100 -0
  116. package/src/components/radial-chart/react.tsx +14 -0
  117. package/src/components/sankey-chart/README.md +48 -0
  118. package/src/components/sankey-chart/element.ts +31 -0
  119. package/src/components/sankey-chart/engine.ts +401 -0
  120. package/src/components/sankey-chart/react.tsx +14 -0
  121. package/src/components/sankey-chart/sankey-chart.css +60 -0
  122. package/src/components/scroll-island/README.md +34 -0
  123. package/src/components/scroll-island/element.ts +16 -0
  124. package/src/components/scroll-island/engine.ts +252 -0
  125. package/src/components/scroll-island/react.tsx +26 -0
  126. package/src/components/scroll-island/scroll-island.css +110 -0
  127. package/src/components/select/README.md +34 -0
  128. package/src/components/select/element.ts +26 -0
  129. package/src/components/select/engine.ts +267 -0
  130. package/src/components/select/react.tsx +33 -0
  131. package/src/components/select/select.css +135 -0
  132. package/src/components/toolbar/README.md +46 -0
  133. package/src/components/toolbar/element.ts +30 -0
  134. package/src/components/toolbar/engine.ts +230 -0
  135. package/src/components/toolbar/react.tsx +34 -0
  136. package/src/components/toolbar/toolbar.css +121 -0
  137. package/src/components/tree-view/element.ts +1 -1
  138. package/src/components/tree-view/engine.ts +174 -0
  139. package/src/components/tree-view/react.tsx +7 -3
  140. package/src/components/tree-view/tree-view.css +79 -0
  141. package/src/components/voice-dots/README.md +48 -0
  142. package/src/components/voice-dots/element.ts +45 -0
  143. package/src/components/voice-dots/engine.ts +243 -0
  144. package/src/components/voice-dots/react.tsx +52 -0
  145. package/src/components/voice-dots/voice-dots.css +69 -0
  146. package/src/elements/index.ts +39 -0
  147. package/src/index.ts +16 -0
  148. package/src/motion/liquid.ts +14 -4
  149. package/src/react/index.ts +14 -1
  150. package/dist/chunk-6P4P7NU6.js +0 -1401
  151. package/dist/chunk-6P4P7NU6.js.map +0 -1
  152. package/dist/chunk-CU6SXW3E.js.map +0 -1
  153. package/dist/store-DjkTBX7f.d.ts +0 -195
  154. /package/dist/{chunk-EY4BJKC4.js.map → chunk-6IRJYXJU.js.map} +0 -0
@@ -0,0 +1,230 @@
1
+ import { gsap, EASE, registerMotion } from '../../core/gsap';
2
+ import { reduced } from '../../core/reduced';
3
+ import { createScope } from '../../core/scope';
4
+ import { inert, warnOnce, type Instance } from '../../core/types';
5
+ import { press } from '../../motion/micro';
6
+
7
+ /*
8
+ * Toolbar: a rail of tools where one is chosen, marked by a light blob under
9
+ * its icon. Choosing another stretches the blob like slime: its leading edge
10
+ * reaches the new tool first (.2 s) while it narrows, then the trailing edge
11
+ * lets go and catches up on the swatch curve (.38,.49,0,1.5), so it wobbles
12
+ * into place; choosing the same tool squeezes it. A label slides out beside
13
+ * the tool under the pointer (or focus) and glides between tools. A toolbar
14
+ * for the keyboard: arrows and Home/End move and choose (roving tabindex),
15
+ * each tool says aria-pressed. Vertical by default, or horizontal.
16
+ */
17
+
18
+ export interface ToolbarOptions {
19
+ /** The chosen tool (its data-mk-tool, else its position); default: the first one. */
20
+ value?: string;
21
+ /** Default 'vertical'. */
22
+ orientation?: 'vertical' | 'horizontal';
23
+ /** The label beside the tool under the pointer or focus (default true). */
24
+ tips?: boolean;
25
+ onChange?: (value: string) => void;
26
+ }
27
+
28
+ export interface ToolbarInstance extends Instance {
29
+ setValue(value: string, notify?: boolean): void;
30
+ readonly value: string;
31
+ }
32
+
33
+ export function createToolbar(root: HTMLElement | null, options: ToolbarOptions = {}): ToolbarInstance {
34
+ const tools = root ? [...root.querySelectorAll<HTMLButtonElement>('button')] : [];
35
+ if (!root || !tools.length) {
36
+ if (root) warnOnce('toolbar: needs at least one <button>');
37
+ return inert({ setValue() {}, value: '' });
38
+ }
39
+ registerMotion();
40
+ const scope = createScope();
41
+ const vertical = options.orientation !== 'horizontal';
42
+ const valueOf = (tool: HTMLElement) => tool.dataset.mkTool ?? String(tools.indexOf(tool as HTMLButtonElement));
43
+ const labelOf = (tool: HTMLElement) => tool.getAttribute('aria-label') ?? tool.title ?? tool.textContent?.trim() ?? '';
44
+
45
+ scope.attr(root, 'role', 'toolbar');
46
+ scope.attr(root, 'aria-orientation', vertical ? 'vertical' : 'horizontal');
47
+ scope.attr(root, 'data-mk-orientation', vertical ? 'vertical' : 'horizontal');
48
+ for (const tool of tools) {
49
+ scope.attr(tool, 'type', tool.getAttribute('type') ?? 'button');
50
+ scope.attr(tool, 'aria-pressed', 'false');
51
+ scope.attr(tool, 'tabindex', '-1');
52
+ // A title would show the browser's own tooltip over ours.
53
+ if (options.tips !== false && tool.title) {
54
+ scope.attr(tool, 'aria-label', tool.getAttribute('aria-label') ?? tool.title);
55
+ scope.attr(tool, 'title', null);
56
+ }
57
+ }
58
+
59
+ const blob = document.createElement('span');
60
+ blob.dataset.mkToolbarBlob = '';
61
+ blob.setAttribute('aria-hidden', 'true');
62
+ root.prepend(blob);
63
+ const tip = document.createElement('span');
64
+ tip.dataset.mkToolbarTip = '';
65
+ tip.setAttribute('aria-hidden', 'true');
66
+ tip.hidden = true;
67
+ if (options.tips !== false) root.append(tip);
68
+ scope.add(() => {
69
+ gsap.killTweensOf([blob, tip]);
70
+ blob.remove();
71
+ tip.remove();
72
+ });
73
+ scope.attr(root, 'data-mk-ready', '');
74
+
75
+ const find = (value: string | undefined) => tools.find((tool) => valueOf(tool) === value);
76
+ let current = find(options.value) ?? tools.find((tool) => !tool.disabled) ?? tools[0]!;
77
+
78
+ /** Where a tool sits inside the rail (offsets, so transforms on the rail do not count). */
79
+ const box = (tool: HTMLElement) => ({ start: vertical ? tool.offsetTop : tool.offsetLeft, size: vertical ? tool.offsetHeight : tool.offsetWidth, cross: vertical ? tool.offsetLeft : tool.offsetTop, crossSize: vertical ? tool.offsetWidth : tool.offsetHeight });
80
+ const along = vertical ? 'y' : 'x';
81
+ const across = vertical ? 'x' : 'y';
82
+ const length = vertical ? 'height' : 'width';
83
+ const thickness = vertical ? 'width' : 'height';
84
+
85
+ let timeline: gsap.core.Timeline | null = null;
86
+ const place = () => {
87
+ const at = box(current);
88
+ timeline?.kill();
89
+ gsap.set(blob, { [along]: at.start, [across]: at.cross, [length]: at.size, [thickness]: at.crossSize, scaleX: 1, scaleY: 1 });
90
+ };
91
+
92
+ /** The slime: the leading edge reaches the tool, then the trailing edge lets go and wobbles in. */
93
+ const slide = (from: HTMLElement, to: HTMLElement) => {
94
+ if (reduced() || from === to) return place();
95
+ const a = box(from);
96
+ const b = box(to);
97
+ const forward = b.start > a.start;
98
+ const reach = forward ? b.start + b.size - a.start : a.start + a.size - b.start;
99
+ const squeeze = vertical ? { scaleX: 0.84 } : { scaleY: 0.84 };
100
+ const relax = vertical ? { scaleX: 1 } : { scaleY: 1 };
101
+ timeline?.kill();
102
+ timeline = gsap.timeline();
103
+ timeline.to(blob, { [along]: forward ? a.start : b.start, [length]: reach, ...squeeze, duration: 0.2, ease: 'power2.in' }, 0);
104
+ timeline.to(blob, { [along]: b.start, [length]: b.size, duration: 0.42, ease: EASE.swatch }, 0.16);
105
+ timeline.to(blob, { ...relax, duration: 0.5, ease: 'elastic.out(1, 0.45)' }, 0.18);
106
+ };
107
+
108
+ const squish = () => {
109
+ if (reduced()) return;
110
+ timeline?.kill();
111
+ place();
112
+ timeline = gsap.timeline();
113
+ timeline.to(blob, { scaleX: 0.88, scaleY: 0.88, duration: 0.1, ease: 'power2.out' }).to(blob, { scaleX: 1, scaleY: 1, duration: 0.45, ease: 'elastic.out(1, 0.4)' });
114
+ };
115
+
116
+ const paint = () => {
117
+ for (const tool of tools) {
118
+ tool.setAttribute('aria-pressed', String(tool === current));
119
+ tool.setAttribute('tabindex', tool === current ? '0' : '-1');
120
+ }
121
+ };
122
+
123
+ // ── The label beside a tool ───────────────────────────────────────────────
124
+ let tipFor: HTMLElement | null = null;
125
+ let tipTimer: ReturnType<typeof setTimeout> | undefined;
126
+ scope.add(() => clearTimeout(tipTimer));
127
+ const showTip = (tool: HTMLElement, linger = 0) => {
128
+ if (options.tips === false) return;
129
+ clearTimeout(tipTimer);
130
+ const text = labelOf(tool);
131
+ if (!text) return hideTip();
132
+ const at = box(tool);
133
+ const appearing = tip.hidden;
134
+ const changed = tipFor !== tool;
135
+ tipFor = tool;
136
+ tip.textContent = text;
137
+ tip.hidden = false;
138
+ const tipSize = vertical ? tip.offsetHeight : tip.offsetWidth;
139
+ const spot = at.start + at.size / 2 - tipSize / 2;
140
+ if (appearing || reduced()) {
141
+ gsap.killTweensOf(tip);
142
+ gsap.set(tip, { [along]: spot });
143
+ if (!reduced()) gsap.fromTo(tip, { opacity: 0, [across]: -8, filter: 'blur(4px)', scale: 0.9 }, { opacity: 1, [across]: 0, filter: 'blur(0px)', scale: 1, duration: 0.28, ease: EASE.pop });
144
+ else gsap.set(tip, { opacity: 1, [across]: 0, scale: 1, filter: 'none' });
145
+ } else if (changed) {
146
+ gsap.to(tip, { [along]: spot, opacity: 1, [across]: 0, scale: 1, filter: 'blur(0px)', duration: 0.32, ease: EASE.swatch, overwrite: 'auto' });
147
+ }
148
+ if (linger) tipTimer = setTimeout(hideTip, linger);
149
+ };
150
+ const hideTip = () => {
151
+ clearTimeout(tipTimer);
152
+ tipFor = null;
153
+ if (tip.hidden) return;
154
+ if (reduced()) {
155
+ tip.hidden = true;
156
+ return;
157
+ }
158
+ gsap.to(tip, { opacity: 0, [across]: -6, filter: 'blur(4px)', scale: 0.92, duration: 0.18, ease: 'power2.in', overwrite: 'auto', onComplete: () => {
159
+ tip.hidden = true;
160
+ } });
161
+ };
162
+
163
+ const choose = (tool: HTMLElement, notify: boolean) => {
164
+ if ((tool as HTMLButtonElement).disabled) return;
165
+ const previous = current;
166
+ current = tool as HTMLButtonElement;
167
+ paint();
168
+ if (previous === current) squish();
169
+ else slide(previous, current);
170
+ if (previous === current || !notify) return;
171
+ const value = valueOf(current);
172
+ options.onChange?.(value);
173
+ root.dispatchEvent(new CustomEvent('mk:change', { bubbles: true, detail: { value } }));
174
+ };
175
+
176
+ scope.on(root, 'click', (event) => {
177
+ const tool = (event.target as Element).closest<HTMLButtonElement>('button');
178
+ if (!tool || !tools.includes(tool)) return;
179
+ press(tool);
180
+ choose(tool, true);
181
+ showTip(tool, 1200);
182
+ });
183
+ scope.on(root, 'pointerover', (event) => {
184
+ const tool = (event.target as Element).closest<HTMLButtonElement>('button');
185
+ if (tool && tools.includes(tool) && (event as PointerEvent).pointerType !== 'touch') showTip(tool);
186
+ });
187
+ scope.on(root, 'pointerleave', () => {
188
+ tipTimer = setTimeout(hideTip, 120);
189
+ });
190
+ scope.on(root, 'focusin', (event) => {
191
+ const tool = event.target as HTMLElement;
192
+ if (tools.includes(tool as HTMLButtonElement) && tool.matches(':focus-visible')) showTip(tool);
193
+ });
194
+ scope.on(root, 'focusout', (event) => {
195
+ if (!root.contains((event as FocusEvent).relatedTarget as Node)) hideTip();
196
+ });
197
+ scope.on(root, 'keydown', (event) => {
198
+ const key = (event as KeyboardEvent).key;
199
+ const enabled = tools.filter((tool) => !tool.disabled);
200
+ const at = enabled.indexOf(document.activeElement as HTMLButtonElement);
201
+ if (at < 0) return;
202
+ const next =
203
+ key === 'ArrowDown' || key === 'ArrowRight' ? enabled[(at + 1) % enabled.length]
204
+ : key === 'ArrowUp' || key === 'ArrowLeft' ? enabled[(at - 1 + enabled.length) % enabled.length]
205
+ : key === 'Home' ? enabled[0]
206
+ : key === 'End' ? enabled[enabled.length - 1]
207
+ : null;
208
+ if (!next) return;
209
+ event.preventDefault();
210
+ choose(next, true);
211
+ next.focus();
212
+ showTip(next, 1200);
213
+ });
214
+
215
+ if (typeof ResizeObserver === 'function') scope.observe(new ResizeObserver(() => place())).observe(root);
216
+
217
+ paint();
218
+ place();
219
+
220
+ return {
221
+ setValue(value, notify = false) {
222
+ const tool = find(value);
223
+ if (tool) choose(tool, notify);
224
+ },
225
+ get value() {
226
+ return valueOf(current);
227
+ },
228
+ destroy: () => scope.dispose(),
229
+ };
230
+ }
@@ -0,0 +1,34 @@
1
+ 'use client';
2
+ import { useRef, type HTMLAttributes, type MutableRefObject } from 'react';
3
+ import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
+ import { createToolbar, type ToolbarInstance, type ToolbarOptions } from './engine';
5
+
6
+ export interface ToolbarProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onChange'>, ToolbarOptions {
7
+ instanceRef?: MutableRefObject<ToolbarInstance | null>;
8
+ }
9
+
10
+ /** <Toolbar aria-label="Herramientas" value={tool} onChange={setTool}><button data-mk-tool="lapiz" aria-label="Lápiz">…</button></Toolbar> */
11
+ export function Toolbar({ value, orientation, tips, onChange, instanceRef, className, children, ...props }: ToolbarProps) {
12
+ const ref = useRef<HTMLDivElement>(null);
13
+ const changed = useLatest(onChange);
14
+ const instance = useRef<ToolbarInstance | null>(null);
15
+ useIsomorphicLayoutEffect(() => {
16
+ const created = createToolbar(ref.current, { value, orientation, tips, onChange: (next) => changed.current?.(next) });
17
+ instance.current = created;
18
+ if (instanceRef) instanceRef.current = created;
19
+ return () => {
20
+ created.destroy();
21
+ instance.current = null;
22
+ if (instanceRef) instanceRef.current = null;
23
+ };
24
+ // eslint-disable-next-line react-hooks/exhaustive-deps
25
+ }, [orientation, tips]);
26
+ useIsomorphicLayoutEffect(() => {
27
+ if (value !== undefined && instance.current && instance.current.value !== value) instance.current.setValue(value);
28
+ }, [value]);
29
+ return (
30
+ <div {...props} ref={ref} className={['mk-toolbar', className].filter(Boolean).join(' ')}>
31
+ {children}
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,121 @@
1
+ /* toolbar — a dark rail of round tools; a light blob sits under the chosen one and stretches like slime to the next.
2
+ The label beside a tool is a small dark pill. In the dark theme the rail turns light and the blob dark. */
3
+ .mk-toolbar,
4
+ mk-toolbar {
5
+ --mk-toolbar-rail: color-mix(in srgb, var(--mk-text) 86%, var(--mk-bg));
6
+ --mk-toolbar-on-rail: var(--mk-bg);
7
+ --mk-toolbar-blob: var(--mk-bg);
8
+ --mk-toolbar-on-blob: var(--mk-text);
9
+ --mk-toolbar-tool: 3.5rem;
10
+ position: relative;
11
+ display: inline-flex;
12
+ width: max-content;
13
+ max-width: 100%;
14
+ flex-direction: column;
15
+ gap: 0.25rem;
16
+ padding: 0.5rem;
17
+ border-radius: var(--mk-radius-full);
18
+ background: var(--mk-toolbar-rail);
19
+ box-shadow: 0 10px 30px -12px var(--mk-shadow-color, rgb(0 0 0 / 0.35));
20
+ }
21
+
22
+ :is(.mk-toolbar, mk-toolbar)[data-mk-orientation='horizontal'] {
23
+ flex-direction: row;
24
+ }
25
+
26
+ :is(.mk-toolbar, mk-toolbar) > button {
27
+ position: relative;
28
+ z-index: 1;
29
+ display: grid;
30
+ place-items: center;
31
+ width: var(--mk-toolbar-tool);
32
+ height: var(--mk-toolbar-tool);
33
+ padding: 0;
34
+ border: 0;
35
+ border-radius: var(--mk-radius-full);
36
+ background: transparent;
37
+ color: var(--mk-toolbar-on-rail);
38
+ cursor: pointer;
39
+ transition: color 0.2s ease;
40
+ -webkit-tap-highlight-color: transparent;
41
+ }
42
+
43
+ :is(.mk-toolbar, mk-toolbar) > button svg {
44
+ width: 1.5rem;
45
+ height: 1.5rem;
46
+ transition: transform 0.35s var(--mk-ease-swatch);
47
+ }
48
+
49
+ /* The chosen tool's icon turns dark as the blob arrives under it. */
50
+ :is(.mk-toolbar, mk-toolbar) > button[aria-pressed='true'] {
51
+ color: var(--mk-toolbar-on-blob);
52
+ transition-delay: 0.12s;
53
+ }
54
+
55
+ @media (hover: hover) {
56
+ :is(.mk-toolbar, mk-toolbar) > button:hover svg {
57
+ transform: scale(1.1);
58
+ }
59
+ }
60
+
61
+ :is(.mk-toolbar, mk-toolbar) > button:focus-visible {
62
+ outline: 2px solid var(--mk-accent);
63
+ outline-offset: 2px;
64
+ }
65
+
66
+ :is(.mk-toolbar, mk-toolbar) > button:disabled {
67
+ cursor: not-allowed;
68
+ opacity: 0.4;
69
+ }
70
+
71
+ [data-mk-toolbar-blob] {
72
+ position: absolute;
73
+ top: 0;
74
+ left: 0;
75
+ width: var(--mk-toolbar-tool);
76
+ height: var(--mk-toolbar-tool);
77
+ border-radius: var(--mk-radius-full);
78
+ background: var(--mk-toolbar-blob);
79
+ pointer-events: none;
80
+ will-change: transform, height, width;
81
+ }
82
+
83
+ [data-mk-toolbar-tip] {
84
+ position: absolute;
85
+ top: 0;
86
+ left: calc(100% + 0.75rem);
87
+ z-index: 2;
88
+ padding: 0.4rem 0.9rem;
89
+ border-radius: var(--mk-radius-full);
90
+ background: var(--mk-toolbar-rail);
91
+ color: var(--mk-toolbar-on-rail);
92
+ font: 500 0.875rem/1.2 var(--mk-font);
93
+ white-space: nowrap;
94
+ pointer-events: none;
95
+ }
96
+
97
+ :is(.mk-toolbar, mk-toolbar)[data-mk-orientation='horizontal'] [data-mk-toolbar-tip] {
98
+ top: auto;
99
+ bottom: calc(100% + 0.75rem);
100
+ left: 0;
101
+ }
102
+
103
+ [data-mk-toolbar-tip][hidden] {
104
+ display: none;
105
+ }
106
+
107
+ @media (prefers-reduced-motion: reduce) {
108
+ :is(.mk-toolbar, mk-toolbar) > button,
109
+ :is(.mk-toolbar, mk-toolbar) > button svg {
110
+ transition: none;
111
+ }
112
+ }
113
+
114
+ @media (forced-colors: active) {
115
+ [data-mk-toolbar-blob] {
116
+ background: Highlight;
117
+ }
118
+ :is(.mk-toolbar, mk-toolbar) > button[aria-pressed='true'] {
119
+ color: HighlightText;
120
+ }
121
+ }
@@ -7,7 +7,7 @@ import { createTreeView, type TreeNode, type TreeViewInstance } from './engine';
7
7
  * Emits mk:select ({id, node}), mk:check ({checked}), mk:expand ({id, expanded}).
8
8
  */
9
9
  export class MkTreeViewElement extends MkElement<TreeViewInstance> {
10
- static options: Record<string, OptionType> = { nodes: 'json', checkable: 'boolean', checked: 'list', expanded: 'list', selected: 'string', search: 'string', label: 'string' };
10
+ static options: Record<string, OptionType> = { nodes: 'json', checkable: 'boolean', checked: 'list', expanded: 'list', selected: 'string', search: 'string', label: 'string', icons: 'boolean', draggable: 'boolean' };
11
11
 
12
12
  protected create(options: Record<string, unknown>) {
13
13
  // search="" (present without text) turns the field on with its default placeholder.
@@ -19,6 +19,11 @@ import { foldText, markText } from '../../core/mark';
19
19
  * the first time it opens (a spinner row meanwhile). Keyboard: ↑ ↓ move, → opens
20
20
  * or enters, ← closes or goes up, Home, End, * opens the siblings, Enter
21
21
  * selects, Space checks (or selects), and typing jumps to a label.
22
+ * Like the file explorer: with `icons`, branches are folders whose lid opens
23
+ * with them and leaves are pages; nodes that arrive (setNodes, a move) come in
24
+ * out of a blur; with `draggable`, a dragged row lifts a card that follows the
25
+ * pointer and tilts with its speed, the folder under it opens its lid (and the
26
+ * branch after a moment), and dropping flies the card into it.
22
27
  */
23
28
 
24
29
  export type { TreeNode } from '../../core/tree';
@@ -44,6 +49,12 @@ export interface TreeViewOptions {
44
49
  onSelect?: (node: TreeNode) => void;
45
50
  onCheck?: (checked: string[]) => void;
46
51
  onExpand?: (node: TreeNode, expanded: boolean) => void;
52
+ /** Folder and page icons (default false); a node's own `icon` still wins. */
53
+ icons?: boolean;
54
+ /** Rows can be dragged into another branch (default false). */
55
+ draggable?: boolean;
56
+ /** A node was dropped into a branch (the tree has moved it). */
57
+ onMove?: (node: TreeNode, into: TreeNode) => void;
47
58
  }
48
59
 
49
60
  export interface TreeViewInstance extends Instance {
@@ -66,6 +77,30 @@ export interface TreeViewInstance extends Instance {
66
77
  readonly element: HTMLElement;
67
78
  }
68
79
 
80
+ const FOLDER =
81
+ '<svg aria-hidden="true" viewBox="0 0 48 40"><path data-mk-tree-folder-back d="M2 6a4 4 0 0 1 4-4h11.6a4 4 0 0 1 2.9 1.2L23.4 6H42a4 4 0 0 1 4 4v24a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4z"/><path data-mk-tree-folder-front d="M2 14a4 4 0 0 1 4-4h36a4 4 0 0 1 4 4v20a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4z"/></svg>';
82
+ const PAGE =
83
+ '<svg aria-hidden="true" viewBox="0 0 40 48"><path data-mk-tree-sheet d="M7 2h18.5L37 13.5V42a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4z"/><path data-mk-tree-fold d="M25.5 2v8.5a3 3 0 0 0 3 3H37z"/></svg>';
84
+
85
+ /** `nodes` with the node `id` moved to the end of `into`'s children (a new tree: the given one is not changed). */
86
+ export function moveTreeNode(nodes: TreeNode[], id: string, into: string): TreeNode[] {
87
+ let taken: TreeNode | null = null;
88
+ const without = (list: TreeNode[]): TreeNode[] =>
89
+ list
90
+ .filter((node) => {
91
+ if (node.id !== id) return true;
92
+ taken = node;
93
+ return false;
94
+ })
95
+ .map((node) => (node.children ? { ...node, children: without(node.children) } : node));
96
+ const rest = without(nodes);
97
+ const moved = taken as TreeNode | null;
98
+ if (!moved) return nodes;
99
+ const put = (list: TreeNode[]): TreeNode[] =>
100
+ list.map((node) => (node.id === into ? { ...node, children: [...(node.children ?? []), moved] } : node.children ? { ...node, children: put(node.children) } : node));
101
+ return put(rest);
102
+ }
103
+
69
104
  const CHEVRON = '<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg>';
70
105
  const CHECK = '<svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path data-mk-tree-tick d="M5 12.5l4.5 4.5L19 7.5" pathLength="1"/><path data-mk-tree-dash d="M6 12h12"/></svg>';
71
106
  let sequence = 0;
@@ -201,6 +236,12 @@ export function createTreeView(root: HTMLElement | null, options: TreeViewOption
201
236
  icon.setAttribute('aria-hidden', 'true');
202
237
  icon.textContent = node.icon;
203
238
  line.append(icon);
239
+ } else if (options.icons) {
240
+ const icon = document.createElement('span');
241
+ if (branch) icon.dataset.mkTreeFolder = '';
242
+ else icon.dataset.mkTreePage = '';
243
+ icon.innerHTML = branch ? FOLDER : PAGE;
244
+ line.append(icon);
204
245
  }
205
246
  const label = document.createElement('span');
206
247
  label.dataset.mkTreeLabel = '';
@@ -244,12 +285,30 @@ export function createTreeView(root: HTMLElement | null, options: TreeViewOption
244
285
  for (const item of tree.querySelectorAll<HTMLElement>('[role="treeitem"]')) item.tabIndex = item === target ? 0 : -1;
245
286
  };
246
287
 
288
+ let known: Set<string> | null = null;
289
+ const allIds = (list: TreeNode[], into = new Set<string>()) => {
290
+ for (const node of list) {
291
+ into.add(node.id);
292
+ if (node.children) allIds(node.children, into);
293
+ }
294
+ return into;
295
+ };
296
+ /** Rows of nodes that just arrived come in out of a blur (not on the first drawing). */
297
+ const welcome = () => {
298
+ const now = allIds(nodes);
299
+ const fresh = known ? [...now].filter((id) => !known!.has(id)) : [];
300
+ known = now;
301
+ if (reduced() || !fresh.length) return;
302
+ const rows = fresh.map((id) => itemOf(id)?.querySelector<HTMLElement>(':scope > [data-mk-tree-row]')).filter((line): line is HTMLElement => !!line && visibleItems().includes(line.parentElement!));
303
+ if (rows.length) keep(gsap.fromTo(rows, { opacity: 0, x: -8, filter: 'blur(6px)' }, { opacity: 1, x: 0, filter: 'blur(0px)', duration: 0.35, stagger: 0.03, ease: 'power2.out', clearProps: 'opacity,transform,filter' }));
304
+ };
247
305
  const render = () => {
248
306
  tree.replaceChildren(...nodes.map((node, i) => row(node, 0, i, nodes.length)));
249
307
  paintChecks(false);
250
308
  applySearch(false);
251
309
  roving();
252
310
  placePill(false);
311
+ welcome();
253
312
  };
254
313
 
255
314
  // ── Opening and closing ──────────────────────────────────────────────────
@@ -401,6 +460,121 @@ export function createTreeView(root: HTMLElement | null, options: TreeViewOption
401
460
  });
402
461
  }
403
462
 
463
+ // ── Dragging into another branch ─────────────────────────────────────────
464
+ if (options.draggable) {
465
+ let drag: { id: string; x: number; y: number; pointer: number; card: HTMLElement | null; over: HTMLElement | null; lastX: number; lastT: number } | null = null;
466
+ let opening: ReturnType<typeof setTimeout> | undefined;
467
+ const clearOver = () => {
468
+ clearTimeout(opening);
469
+ drag?.over?.removeAttribute('data-mk-tree-over');
470
+ if (drag) drag.over = null;
471
+ };
472
+ /** The branch a node may go into under the pointer (a leaf counts as its branch), or null. */
473
+ const branchAt = (x: number, y: number) => {
474
+ const under = document.elementFromPoint(x, y)?.closest<HTMLElement>('[role="treeitem"]');
475
+ if (!under || !tree.contains(under) || !drag) return null;
476
+ const node = nodeOf(under.dataset.id!);
477
+ const candidate = node && isBranch(node) ? under : under.parentElement?.closest<HTMLElement>('[role="treeitem"]') ?? null;
478
+ if (!candidate) return null;
479
+ const path = findNode(nodes, candidate.dataset.id!)?.path.map((each) => each.id) ?? [];
480
+ // Not into itself, one of its own branches, or where it already is.
481
+ const parent = findNode(nodes, drag.id)?.path.at(-2)?.id;
482
+ if (path.includes(drag.id) || candidate.dataset.id === parent) return null;
483
+ return candidate;
484
+ };
485
+ const lift = (id: string, x: number, y: number) => {
486
+ const node = nodeOf(id)!;
487
+ const card = document.createElement('div');
488
+ card.dataset.mkTreeDrag = '';
489
+ card.setAttribute('aria-hidden', 'true');
490
+ if (options.icons) {
491
+ const icon = document.createElement('span');
492
+ if (isBranch(node)) icon.dataset.mkTreeFolder = '';
493
+ else icon.dataset.mkTreePage = '';
494
+ icon.innerHTML = isBranch(node) ? FOLDER : PAGE;
495
+ card.append(icon);
496
+ }
497
+ const text = document.createElement('span');
498
+ text.textContent = node.label;
499
+ card.append(text);
500
+ Object.assign(card.style, { left: '0px', top: '0px' });
501
+ root.append(card);
502
+ gsap.set(card, { x: x + 14, y: y + 10 });
503
+ if (!reduced()) keep(gsap.fromTo(card, { scale: 0.85, opacity: 0 }, { scale: 1.04, opacity: 1, duration: 0.3, ease: EASE.pop }));
504
+ itemOf(id)?.setAttribute('data-mk-tree-dragging', '');
505
+ return card;
506
+ };
507
+ const end = (drop: boolean) => {
508
+ if (!drag) return;
509
+ const { id, card, over } = drag;
510
+ clearOver();
511
+ drag = null;
512
+ itemOf(id)?.removeAttribute('data-mk-tree-dragging');
513
+ if (!card) return;
514
+ const into = drop && over ? over.dataset.id! : null;
515
+ const fly = (to: HTMLElement | null) => {
516
+ const box = to?.querySelector<HTMLElement>(':scope > [data-mk-tree-row]')?.getBoundingClientRect();
517
+ if (reduced() || !box || !box.width) return card.remove();
518
+ keep(gsap.to(card, { x: box.left + 24, y: box.top + box.height / 2 - 14, scale: 0.4, opacity: 0, duration: 0.32, ease: 'power2.in', onComplete: () => card.remove() }));
519
+ };
520
+ if (!into) return fly(itemOf(id));
521
+ fly(over);
522
+ const node = nodeOf(id)!;
523
+ nodes = moveTreeNode(nodes, id, into);
524
+ expanded.add(into);
525
+ render();
526
+ const target = nodeOf(into)!;
527
+ options.onMove?.(nodeOf(id)!, target);
528
+ root.dispatchEvent(new CustomEvent('mk:move', { bubbles: true, detail: { node, into: target } }));
529
+ };
530
+ scope.on(tree, 'pointerdown', (event) => {
531
+ const down = event as PointerEvent;
532
+ const target = down.target as Element;
533
+ const line = target.closest<HTMLElement>('[data-mk-tree-row]');
534
+ const item = line?.parentElement;
535
+ if (!line || !item || down.button !== 0 || target.closest('[data-mk-tree-chevron], [data-mk-tree-check]') || item.getAttribute('aria-disabled') === 'true') return;
536
+ drag = { id: item.dataset.id!, x: down.clientX, y: down.clientY, pointer: down.pointerId, card: null, over: null, lastX: down.clientX, lastT: performance.now() };
537
+ });
538
+ scope.on(window, 'pointermove', (event) => {
539
+ const move = event as PointerEvent;
540
+ if (!drag || move.pointerId !== drag.pointer) return;
541
+ if (!drag.card) {
542
+ if (Math.hypot(move.clientX - drag.x, move.clientY - drag.y) < 6) return;
543
+ drag.card = lift(drag.id, move.clientX, move.clientY);
544
+ }
545
+ // The card follows and leans with the pointer's speed.
546
+ const now = performance.now();
547
+ const speed = (move.clientX - drag.lastX) / Math.max(1, now - drag.lastT);
548
+ drag.lastX = move.clientX;
549
+ drag.lastT = now;
550
+ gsap.set(drag.card, { x: move.clientX + 14, y: move.clientY + 10 });
551
+ if (!reduced()) keep(gsap.to(drag.card, { rotation: Math.max(-10, Math.min(10, speed * 12)), duration: 0.25, ease: 'power2.out' }));
552
+ const over = branchAt(move.clientX, move.clientY);
553
+ if (over === drag.over) return;
554
+ clearOver();
555
+ if (!over) return;
556
+ drag.over = over;
557
+ over.setAttribute('data-mk-tree-over', '');
558
+ // A closed branch opens after a moment under the card.
559
+ if (over.getAttribute('aria-expanded') === 'false') opening = setTimeout(() => void setOpen(over.dataset.id!, true), 600);
560
+ });
561
+ scope.on(window, 'pointerup', (event) => {
562
+ if (drag && (event as PointerEvent).pointerId === drag.pointer) end(true);
563
+ });
564
+ scope.on(window, 'pointercancel', () => end(false));
565
+ scope.on(document, 'keydown', (event) => {
566
+ if ((event as KeyboardEvent).key === 'Escape' && drag?.card) end(false);
567
+ });
568
+ // A drag is not a click: the row under the pointer keeps its selection.
569
+ scope.on(tree, 'click', (event) => {
570
+ if (drag?.card) event.stopImmediatePropagation();
571
+ }, true);
572
+ scope.add(() => {
573
+ clearTimeout(opening);
574
+ drag?.card?.remove();
575
+ });
576
+ }
577
+
404
578
  // ── Pointer and keyboard ─────────────────────────────────────────────────
405
579
  scope.on(tree, 'click', (event) => {
406
580
  const target = event.target as Element | null;
@@ -3,7 +3,7 @@ import { forwardRef, useImperativeHandle, useRef, type HTMLAttributes } from 're
3
3
  import { useIsomorphicLayoutEffect, useLatest } from '../../react/use-recipe';
4
4
  import { createTreeView, type TreeNode, type TreeViewInstance, type TreeViewOptions } from './engine';
5
5
 
6
- export interface TreeViewProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect' | 'children'>, Omit<TreeViewOptions, 'nodes'> {
6
+ export interface TreeViewProps extends Omit<HTMLAttributes<HTMLDivElement>, 'onSelect' | 'children' | 'draggable'>, Omit<TreeViewOptions, 'nodes'> {
7
7
  nodes: TreeNode[];
8
8
  }
9
9
 
@@ -11,7 +11,7 @@ export type TreeViewHandle = Pick<TreeViewInstance, 'expand' | 'collapse' | 'exp
11
11
 
12
12
  /** <TreeView nodes={tree} checkable checked={ids} onCheck={setIds} search />. */
13
13
  export const TreeView = forwardRef<TreeViewHandle, TreeViewProps>(function TreeView(
14
- { nodes, checkable, checked, expanded, selected, search, label, loadChildren, renderLabel, searchText, onSelect, onCheck, onExpand, className, ...props },
14
+ { nodes, checkable, checked, expanded, selected, search, label, loadChildren, renderLabel, searchText, icons, draggable, onSelect, onCheck, onExpand, onMove, className, ...props },
15
15
  handle,
16
16
  ) {
17
17
  const ref = useRef<HTMLDivElement>(null);
@@ -22,6 +22,7 @@ export const TreeView = forwardRef<TreeViewHandle, TreeViewProps>(function TreeV
22
22
  const load = useLatest(loadChildren);
23
23
  const draw = useLatest(renderLabel);
24
24
  const text = useLatest(searchText);
25
+ const moved = useLatest(onMove);
25
26
  useImperativeHandle(
26
27
  handle,
27
28
  () => ({
@@ -50,6 +51,9 @@ export const TreeView = forwardRef<TreeViewHandle, TreeViewProps>(function TreeV
50
51
  onSelect: (node) => select.current?.(node),
51
52
  onCheck: (ids) => check.current?.(ids),
52
53
  onExpand: (node, open) => expand.current?.(node, open),
54
+ icons,
55
+ draggable,
56
+ onMove: (node, into) => moved.current?.(node, into),
53
57
  });
54
58
  instance.current = created;
55
59
  return () => {
@@ -57,7 +61,7 @@ export const TreeView = forwardRef<TreeViewHandle, TreeViewProps>(function TreeV
57
61
  instance.current = null;
58
62
  };
59
63
  // eslint-disable-next-line react-hooks/exhaustive-deps
60
- }, [checkable, String(search), label, Boolean(loadChildren), Boolean(renderLabel), Boolean(searchText)]);
64
+ }, [checkable, String(search), label, Boolean(loadChildren), Boolean(renderLabel), Boolean(searchText), icons, draggable]);
61
65
  const first = useRef(true);
62
66
  useIsomorphicLayoutEffect(() => {
63
67
  if (first.current) {