@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.
- package/README.md +174 -155
- package/bin/motion-kit.mjs +3 -3
- package/dist/auto/index.d.ts +6 -3
- package/dist/auto/index.js +8 -2
- package/dist/auto/index.js.map +1 -1
- package/dist/{chunk-EY4BJKC4.js → chunk-6IRJYXJU.js} +2 -2
- package/dist/{chunk-CU6SXW3E.js → chunk-A6QQCF6V.js} +1309 -310
- package/dist/chunk-A6QQCF6V.js.map +1 -0
- package/dist/chunk-JXRS3OEA.js +3638 -0
- package/dist/chunk-JXRS3OEA.js.map +1 -0
- package/dist/css/components/add-to-cart.css +72 -0
- package/dist/css/components/bidirectional-slider.css +168 -0
- package/dist/css/components/data-table.css +121 -26
- package/dist/css/components/filter-builder.css +10 -15
- package/dist/css/components/heatmap-chart.css +391 -0
- package/dist/css/components/hover-card.css +8 -2
- package/dist/css/components/image-preview.css +90 -2
- package/dist/css/components/json-viewer.css +33 -1
- package/dist/css/components/nav.css +102 -102
- package/dist/css/components/orbit-menu.css +130 -0
- package/dist/css/components/pagination.css +10 -5
- package/dist/css/components/pie-chart.css +390 -0
- package/dist/css/components/pill-button.css +135 -0
- package/dist/css/components/radar-chart.css +370 -0
- package/dist/css/components/radial-chart.css +398 -0
- package/dist/css/components/sankey-chart.css +358 -0
- package/dist/css/components/scroll-island.css +111 -0
- package/dist/css/components/select.css +136 -0
- package/dist/css/components/toolbar.css +122 -0
- package/dist/css/components/tree-view.css +79 -0
- package/dist/css/components/voice-dots.css +70 -0
- package/dist/elements/index.d.ts +180 -7
- package/dist/elements/index.js +287 -6
- package/dist/elements/index.js.map +1 -1
- package/dist/{engine-Cn6IhE3z.d.ts → engine-5zRIssTO.d.ts} +11 -1
- package/dist/index.css +1832 -168
- package/dist/index.d.ts +4 -4
- package/dist/index.js +33 -3
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.ts +117 -11
- package/dist/react/index.js +314 -13
- package/dist/react/index.js.map +1 -1
- package/dist/{reduced-BBUusld6.d.ts → reduced-6X_bns60.d.ts} +1 -1
- package/dist/{reveal-DEvp6U6T.d.ts → reveal-BfyKKsw4.d.ts} +97 -4
- package/dist/store-CA2Qcun_.d.ts +453 -0
- package/package.json +9 -16
- package/registry.json +1096 -948
- package/src/auto/auto-init.ts +6 -0
- package/src/components/add-to-cart/README.md +34 -0
- package/src/components/add-to-cart/add-to-cart.css +71 -0
- package/src/components/add-to-cart/element.ts +20 -0
- package/src/components/add-to-cart/engine.ts +206 -0
- package/src/components/add-to-cart/react.tsx +29 -0
- package/src/components/bidirectional-slider/README.md +46 -0
- package/src/components/bidirectional-slider/bidirectional-slider.css +167 -0
- package/src/components/bidirectional-slider/element.ts +42 -0
- package/src/components/bidirectional-slider/engine.ts +289 -0
- package/src/components/bidirectional-slider/react.tsx +45 -0
- package/src/components/chart/README.md +10 -0
- package/src/components/chart/element.ts +2 -0
- package/src/components/chart/engine.ts +50 -23
- package/src/components/chart/parts.ts +237 -0
- package/src/components/chart/react.tsx +2 -2
- package/src/components/chart/use-chart.ts +55 -0
- package/src/components/data-table/README.md +3 -3
- package/src/components/data-table/data-table.css +121 -26
- package/src/components/data-table/engine.ts +81 -29
- package/src/components/editable-table/engine.ts +67 -11
- package/src/components/filter-builder/engine.ts +33 -2
- package/src/components/filter-builder/filter-builder.css +10 -15
- package/src/components/heatmap-chart/README.md +48 -0
- package/src/components/heatmap-chart/element.ts +21 -0
- package/src/components/heatmap-chart/engine.ts +281 -0
- package/src/components/heatmap-chart/heatmap-chart.css +93 -0
- package/src/components/heatmap-chart/react.tsx +14 -0
- package/src/components/hover-card/engine.ts +30 -1
- package/src/components/hover-card/hover-card.css +8 -2
- package/src/components/image-preview/element.ts +1 -1
- package/src/components/image-preview/engine.ts +95 -15
- package/src/components/image-preview/image-preview.css +90 -2
- package/src/components/image-preview/react.tsx +3 -3
- package/src/components/json-viewer/engine.ts +11 -2
- package/src/components/json-viewer/json-viewer.css +33 -1
- package/src/components/nav/README.md +12 -10
- package/src/components/nav/element.ts +5 -5
- package/src/components/nav/engine.ts +21 -14
- package/src/components/nav/nav.css +102 -102
- package/src/components/nav/react.tsx +4 -4
- package/src/components/orbit-menu/README.md +46 -0
- package/src/components/orbit-menu/element.ts +27 -0
- package/src/components/orbit-menu/engine.ts +179 -0
- package/src/components/orbit-menu/orbit-menu.css +129 -0
- package/src/components/orbit-menu/react.tsx +44 -0
- package/src/components/pagination/README.md +1 -1
- package/src/components/pagination/engine.ts +50 -4
- package/src/components/pagination/pagination.css +10 -5
- package/src/components/pie-chart/README.md +48 -0
- package/src/components/pie-chart/element.ts +30 -0
- package/src/components/pie-chart/engine.ts +283 -0
- package/src/components/pie-chart/pie-chart.css +92 -0
- package/src/components/pie-chart/react.tsx +14 -0
- package/src/components/pill-button/README.md +46 -0
- package/src/components/pill-button/element.ts +16 -0
- package/src/components/pill-button/engine.ts +77 -0
- package/src/components/pill-button/pill-button.css +134 -0
- package/src/components/pill-button/react.tsx +32 -0
- package/src/components/radar-chart/README.md +48 -0
- package/src/components/radar-chart/element.ts +30 -0
- package/src/components/radar-chart/engine.ts +327 -0
- package/src/components/radar-chart/radar-chart.css +72 -0
- package/src/components/radar-chart/react.tsx +14 -0
- package/src/components/radial-chart/README.md +48 -0
- package/src/components/radial-chart/element.ts +30 -0
- package/src/components/radial-chart/engine.ts +290 -0
- package/src/components/radial-chart/radial-chart.css +100 -0
- package/src/components/radial-chart/react.tsx +14 -0
- package/src/components/sankey-chart/README.md +48 -0
- package/src/components/sankey-chart/element.ts +31 -0
- package/src/components/sankey-chart/engine.ts +401 -0
- package/src/components/sankey-chart/react.tsx +14 -0
- package/src/components/sankey-chart/sankey-chart.css +60 -0
- package/src/components/scroll-island/README.md +34 -0
- package/src/components/scroll-island/element.ts +16 -0
- package/src/components/scroll-island/engine.ts +252 -0
- package/src/components/scroll-island/react.tsx +26 -0
- package/src/components/scroll-island/scroll-island.css +110 -0
- package/src/components/select/README.md +34 -0
- package/src/components/select/element.ts +26 -0
- package/src/components/select/engine.ts +267 -0
- package/src/components/select/react.tsx +33 -0
- package/src/components/select/select.css +135 -0
- package/src/components/toolbar/README.md +46 -0
- package/src/components/toolbar/element.ts +30 -0
- package/src/components/toolbar/engine.ts +230 -0
- package/src/components/toolbar/react.tsx +34 -0
- package/src/components/toolbar/toolbar.css +121 -0
- package/src/components/tree-view/element.ts +1 -1
- package/src/components/tree-view/engine.ts +174 -0
- package/src/components/tree-view/react.tsx +7 -3
- package/src/components/tree-view/tree-view.css +79 -0
- package/src/components/voice-dots/README.md +48 -0
- package/src/components/voice-dots/element.ts +45 -0
- package/src/components/voice-dots/engine.ts +243 -0
- package/src/components/voice-dots/react.tsx +52 -0
- package/src/components/voice-dots/voice-dots.css +69 -0
- package/src/elements/index.ts +39 -0
- package/src/index.ts +16 -0
- package/src/motion/liquid.ts +14 -4
- package/src/react/index.ts +14 -1
- package/dist/chunk-6P4P7NU6.js +0 -1401
- package/dist/chunk-6P4P7NU6.js.map +0 -1
- package/dist/chunk-CU6SXW3E.js.map +0 -1
- package/dist/store-DjkTBX7f.d.ts +0 -195
- /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) {
|