@ruckc/truss-ui 1.2.0 → 1.4.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 (85) hide show
  1. package/README.md +6 -1
  2. package/dist/commands.svelte.d.ts +41 -0
  3. package/dist/commands.svelte.js +59 -0
  4. package/dist/components/Accordion.svelte +1 -1
  5. package/dist/components/Button.svelte +65 -8
  6. package/dist/components/Button.svelte.d.ts +2 -0
  7. package/dist/components/Calendar.svelte +2 -2
  8. package/dist/components/Card.svelte +1 -0
  9. package/dist/components/Card.svelte.d.ts +1 -0
  10. package/dist/components/Checkbox.svelte +1 -1
  11. package/dist/components/CommandPalette.svelte +207 -0
  12. package/dist/components/CommandPalette.svelte.d.ts +39 -0
  13. package/dist/components/DataTable.svelte +201 -27
  14. package/dist/components/DataTable.svelte.d.ts +50 -4
  15. package/dist/components/Dialog.svelte +2 -1
  16. package/dist/components/Drawer.svelte +1 -1
  17. package/dist/components/EditableText.svelte +2 -2
  18. package/dist/components/Eyebrow.svelte +1 -1
  19. package/dist/components/FileUpload.svelte +1 -1
  20. package/dist/components/Footer.svelte +29 -2
  21. package/dist/components/Footer.svelte.d.ts +8 -0
  22. package/dist/components/GridLayout.svelte +242 -0
  23. package/dist/components/GridLayout.svelte.d.ts +30 -0
  24. package/dist/components/HoverCard.svelte +158 -0
  25. package/dist/components/HoverCard.svelte.d.ts +46 -0
  26. package/dist/components/Kbd.svelte +36 -0
  27. package/dist/components/Kbd.svelte.d.ts +8 -0
  28. package/dist/components/Link.svelte +1 -1
  29. package/dist/components/List.svelte +113 -0
  30. package/dist/components/List.svelte.d.ts +17 -0
  31. package/dist/components/ListItem.svelte +13 -0
  32. package/dist/components/ListItem.svelte.d.ts +9 -0
  33. package/dist/components/Menu.svelte +4 -3
  34. package/dist/components/MultiSelect.svelte +2 -2
  35. package/dist/components/Navbar.svelte +13 -3
  36. package/dist/components/Navbar.svelte.d.ts +5 -1
  37. package/dist/components/Panel.svelte +2 -2
  38. package/dist/components/Popover.svelte +22 -1
  39. package/dist/components/Popover.svelte.d.ts +2 -0
  40. package/dist/components/RadioGroup.svelte +1 -1
  41. package/dist/components/Select.svelte +1 -1
  42. package/dist/components/Shell.svelte +12 -1
  43. package/dist/components/Shell.svelte.d.ts +2 -0
  44. package/dist/components/ShortcutSheet.svelte +62 -0
  45. package/dist/components/ShortcutSheet.svelte.d.ts +9 -0
  46. package/dist/components/Sidebar.svelte +2 -2
  47. package/dist/components/Skeleton.svelte +80 -6
  48. package/dist/components/Skeleton.svelte.d.ts +8 -1
  49. package/dist/components/Slider.svelte +1 -1
  50. package/dist/components/Switch.svelte +1 -1
  51. package/dist/components/Tabs.svelte +252 -32
  52. package/dist/components/Tabs.svelte.d.ts +21 -9
  53. package/dist/components/Tag.svelte +7 -7
  54. package/dist/components/TagInput.svelte +1 -1
  55. package/dist/components/Text.svelte +1 -1
  56. package/dist/components/Toaster.svelte +33 -2
  57. package/dist/components/ToggleChip.svelte +1 -1
  58. package/dist/components/Toolbar.svelte +27 -0
  59. package/dist/components/Toolbar.svelte.d.ts +10 -0
  60. package/dist/components/ToolbarSeparator.svelte +15 -0
  61. package/dist/components/ToolbarSeparator.svelte.d.ts +3 -0
  62. package/dist/components/Tooltip.svelte +19 -28
  63. package/dist/components/Tooltip.svelte.d.ts +6 -1
  64. package/dist/components/TooltipTip.svelte +59 -0
  65. package/dist/components/TooltipTip.svelte.d.ts +12 -0
  66. package/dist/components/Tree.svelte +343 -44
  67. package/dist/components/Tree.svelte.d.ts +29 -2
  68. package/dist/grid-layout.d.ts +42 -0
  69. package/dist/grid-layout.js +100 -0
  70. package/dist/hotkeys.svelte.d.ts +45 -0
  71. package/dist/hotkeys.svelte.js +108 -0
  72. package/dist/index.d.ts +21 -2
  73. package/dist/index.js +13 -0
  74. package/dist/keys.d.ts +25 -0
  75. package/dist/keys.js +88 -0
  76. package/dist/roving.d.ts +25 -0
  77. package/dist/roving.js +129 -0
  78. package/dist/styles/base.css +2 -0
  79. package/dist/styles/tokens.css +49 -0
  80. package/dist/table-export.d.ts +24 -0
  81. package/dist/table-export.js +130 -0
  82. package/dist/toast.svelte.d.ts +25 -1
  83. package/dist/toast.svelte.js +42 -4
  84. package/dist/types.d.ts +6 -0
  85. package/package.json +7 -3
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import Kbd from './Kbd.svelte';
4
+
5
+ /** The floating bubble behind Tooltip and Button's `shortcut`: positions itself beside `anchor`, flipping to stay on screen. */
6
+ let {
7
+ anchor,
8
+ id,
9
+ text,
10
+ content,
11
+ shortcut,
12
+ placement = 'top'
13
+ }: {
14
+ anchor: HTMLElement;
15
+ id: string;
16
+ text?: string;
17
+ content?: Snippet;
18
+ shortcut?: string;
19
+ placement?: 'top' | 'bottom' | 'left' | 'right';
20
+ } = $props();
21
+
22
+ let ready = $state(false);
23
+ let x = $state(0);
24
+ let y = $state(0);
25
+ let tip = $state<HTMLDivElement>();
26
+
27
+ $effect(() => {
28
+ if (!tip) return;
29
+ const r = anchor.getBoundingClientRect();
30
+ const t = tip.getBoundingClientRect();
31
+ let p = placement;
32
+ if (p === 'top' && r.top < t.height + 10) p = 'bottom';
33
+ else if (p === 'bottom' && r.bottom + t.height + 10 > innerHeight) p = 'top';
34
+ else if (p === 'left' && r.left < t.width + 10) p = 'right';
35
+ else if (p === 'right' && r.right + t.width + 10 > innerWidth) p = 'left';
36
+ const cx = r.left + r.width / 2 - t.width / 2, cy = r.top + r.height / 2 - t.height / 2;
37
+ const pos = { top: [cx, r.top - t.height - 6], bottom: [cx, r.bottom + 6], left: [r.left - t.width - 6, cy], right: [r.right + 6, cy] }[p];
38
+ x = Math.min(Math.max(4, pos[0]), innerWidth - t.width - 4);
39
+ y = Math.max(4, pos[1]);
40
+ ready = true;
41
+ });
42
+ </script>
43
+
44
+ <div class="tip" role="tooltip" {id} bind:this={tip} style:left="{x}px" style:top="{y}px" data-ready={ready}>
45
+ {#if text}<span class="txt">{text}</span>{/if}
46
+ {#if content}<span class="rich">{@render content()}</span>{/if}
47
+ {#if shortcut}<Kbd keys={shortcut} />{/if}
48
+ </div>
49
+
50
+ <style>
51
+ .tip {
52
+ position: fixed; z-index: 200; max-width: 260px; padding: 4px 8px; pointer-events: none; visibility: hidden;
53
+ display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px;
54
+ background: var(--tr-on-surface); color: var(--tr-surface); border-radius: var(--tr-radius-sm);
55
+ font: 500 var(--tr-text-sm) / 1.35 var(--tr-font-sans); box-shadow: var(--tr-elevation-2);
56
+ }
57
+ .tip[data-ready='true'] { visibility: visible; animation: fade var(--tr-dur-fast) var(--tr-ease); }
58
+ @keyframes fade { from { opacity: 0; } }
59
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ anchor: HTMLElement;
4
+ id: string;
5
+ text?: string;
6
+ content?: Snippet;
7
+ shortcut?: string;
8
+ placement?: 'top' | 'bottom' | 'left' | 'right';
9
+ };
10
+ declare const TooltipTip: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type TooltipTip = ReturnType<typeof TooltipTip>;
12
+ export default TooltipTip;
@@ -1,24 +1,45 @@
1
1
  <script lang="ts">
2
- import { tick } from 'svelte';
2
+ import { tick, type Snippet } from 'svelte';
3
3
  import { SvelteMap } from 'svelte/reactivity';
4
4
  import Icon from '../icons/Icon.svelte';
5
5
  import Checkbox from './Checkbox.svelte';
6
6
  import Spinner from './Spinner.svelte';
7
- import type { TreeNode } from '../types.js';
7
+ import Menu from './Menu.svelte';
8
+ import Popover from './Popover.svelte';
9
+ import type { TreeNode, MenuEntry } from '../types.js';
10
+
11
+ type DropPosition = 'before' | 'after' | 'inside';
8
12
 
9
13
  let {
10
14
  nodes,
11
15
  expanded = $bindable([]),
12
16
  selected = $bindable(),
17
+ multiple = false,
18
+ selectedIds = $bindable([]),
13
19
  checkable = false,
14
20
  checked = $bindable([]),
15
21
  loadChildren,
16
22
  label = 'Tree',
17
- onselect
23
+ onselect,
24
+ onactivate,
25
+ nodeMenu,
26
+ nodeActions,
27
+ virtual = false,
28
+ height,
29
+ rowHeight,
30
+ overscan = 8,
31
+ draggable = false,
32
+ canDrop,
33
+ ondrop
18
34
  }: {
19
35
  nodes: TreeNode[];
20
36
  expanded?: string[];
37
+ /** The single selected id, or with `multiple` the most recently picked ("lead") node. */
21
38
  selected?: string;
39
+ /** Multi-select: click, Ctrl/Cmd+click toggles, Shift+click or Shift+Arrow selects a range, Ctrl+A selects all visible nodes. */
40
+ multiple?: boolean;
41
+ /** With `multiple`: the selected ids. */
42
+ selectedIds?: string[];
22
43
  /** Tri-state checkboxes. `checked` holds leaf ids. */
23
44
  checkable?: boolean;
24
45
  checked?: string[];
@@ -26,15 +47,37 @@
26
47
  loadChildren?: (node: TreeNode) => Promise<TreeNode[]>;
27
48
  label?: string;
28
49
  onselect?: (node: TreeNode) => void;
50
+ /** Enter or double-click on an enabled node ("open"). A double-click still toggles an expandable node as well. */
51
+ onactivate?: (node: TreeNode) => void;
52
+ /** Context menu for a node: right-click, the Menu key or Shift+F10. Return an empty list to keep the browser's menu. */
53
+ nodeMenu?: (node: TreeNode) => MenuEntry[];
54
+ /** Trailing actions, revealed on hover and when the row is focused (Tab moves from the row into them). */
55
+ nodeActions?: Snippet<[node: TreeNode]>;
56
+ /** Render only the rows in view (needs `height`; scrolls inside the tree). */
57
+ virtual?: boolean;
58
+ /** Height of the scrolling area (CSS length or px number). Also enables scrolling without `virtual`. */
59
+ height?: string | number;
60
+ /** Row height in px (default: the `--tr-tree-row-h` density token). */
61
+ rowHeight?: number;
62
+ /** Extra rows rendered above and below the visible window. */
63
+ overscan?: number;
64
+ /** Move nodes by pointer drag, or by keyboard: Ctrl+X to cut, then Ctrl+V (inside), Ctrl+Shift+V (after) or Ctrl+Alt+V (before) on the target. */
65
+ draggable?: boolean;
66
+ /** Veto a drop. A node is never offered as a target of itself or its descendants. */
67
+ canDrop?: (sources: TreeNode[], target: TreeNode, position: DropPosition) => boolean;
68
+ /** The tree does not rearrange `nodes`: update your data here. */
69
+ ondrop?: (sources: TreeNode[], target: TreeNode, position: DropPosition) => void;
29
70
  } = $props();
30
71
 
31
72
  const loaded = new SvelteMap<string, TreeNode[]>();
32
73
  let loading = $state<string[]>([]);
33
74
  let focusId = $state<string>();
34
75
  let root: HTMLUListElement;
76
+ let scroller = $state<HTMLDivElement>();
35
77
 
36
78
  const kids = (n: TreeNode) => n.children ?? loaded.get(n.id);
37
79
  const expandable = (n: TreeNode) => !!kids(n)?.length || (!!n.hasChildren && !kids(n));
80
+ const openSet = $derived(new Set(expanded));
38
81
 
39
82
  type Row = { node: TreeNode; depth: number; parent?: TreeNode; pos: number; size: number };
40
83
  const rows = $derived.by(() => {
@@ -42,15 +85,48 @@
42
85
  const walk = (list: TreeNode[], depth: number, parent?: TreeNode) =>
43
86
  list.forEach((node, i) => {
44
87
  out.push({ node, depth, parent, pos: i + 1, size: list.length });
45
- if (expanded.includes(node.id) && kids(node)) walk(kids(node)!, depth + 1, node);
88
+ if (openSet.has(node.id) && kids(node)) walk(kids(node)!, depth + 1, node);
46
89
  });
47
90
  walk(nodes, 0);
48
91
  return out;
49
92
  });
50
- const tabbable = $derived(rows.some((r) => r.node.id === focusId) ? focusId : rows[0]?.node.id);
93
+ const indexOf = $derived(new Map(rows.map((r, i) => [r.node.id, i])));
94
+
95
+ // ---------- windowing
96
+ let probe = $state<HTMLElement>();
97
+ let probeH = $state(28);
98
+ $effect(() => {
99
+ if (!probe) return;
100
+ const read = () => { const h = probe!.getBoundingClientRect().height; if (h > 0) probeH = h; };
101
+ read();
102
+ const ro = new ResizeObserver(read);
103
+ ro.observe(probe);
104
+ return () => ro.disconnect();
105
+ });
106
+ const rowH = $derived(rowHeight ?? probeH);
107
+ let viewH = $state(0);
108
+ let scrollTop = $state(0);
109
+ const start = $derived(virtual ? Math.max(0, Math.min(rows.length, Math.floor(scrollTop / rowH) - overscan)) : 0);
110
+ const end = $derived(virtual ? Math.min(rows.length, Math.ceil((scrollTop + (viewH || 320)) / rowH) + overscan) : rows.length);
111
+ const shown = $derived(rows.slice(start, end));
112
+ const tabbable = $derived.by(() => {
113
+ const i = focusId == null ? undefined : indexOf.get(focusId);
114
+ if (i !== undefined && i >= start && i < end) return focusId;
115
+ return rows[Math.min(start, rows.length - 1)]?.node.id;
116
+ });
117
+ const heightCss = $derived(height == null ? (virtual ? '20rem' : undefined) : typeof height === 'number' ? `${height}px` : height);
118
+
119
+ function reveal(i: number) {
120
+ if (!virtual || !scroller) return;
121
+ const top = i * rowH, bottom = top + rowH;
122
+ let next = scrollTop;
123
+ if (top < scrollTop) next = top;
124
+ else if (bottom > scrollTop + scroller.clientHeight) next = bottom - scroller.clientHeight;
125
+ if (next !== scrollTop) scroller.scrollTop = scrollTop = next;
126
+ }
51
127
 
52
128
  async function expand(n: TreeNode) {
53
- if (!expanded.includes(n.id)) expanded = [...expanded, n.id];
129
+ if (!openSet.has(n.id)) expanded = [...expanded, n.id];
54
130
  if (loadChildren && n.hasChildren && !kids(n) && !loading.includes(n.id)) {
55
131
  loading = [...loading, n.id];
56
132
  try { loaded.set(n.id, await loadChildren(n)); }
@@ -59,7 +135,7 @@
59
135
  }
60
136
  }
61
137
  const collapse = (n: TreeNode) => (expanded = expanded.filter((x) => x !== n.id));
62
- const toggle = (n: TreeNode) => (expanded.includes(n.id) ? collapse(n) : expand(n));
138
+ const toggle = (n: TreeNode) => (openSet.has(n.id) ? collapse(n) : expand(n));
63
139
 
64
140
  function leaves(n: TreeNode): string[] {
65
141
  const k = kids(n);
@@ -77,71 +153,294 @@
77
153
 
78
154
  async function focusRow(id: string) {
79
155
  focusId = id;
156
+ const i = indexOf.get(id);
157
+ if (i !== undefined) reveal(i);
80
158
  await tick();
81
159
  root.querySelector<HTMLElement>(`[data-id="${CSS.escape(id)}"]`)?.focus();
82
160
  }
83
- function pick(n: TreeNode) {
161
+
162
+ // ---------- selection
163
+ const selSet = $derived(new Set(multiple ? (selectedIds.length || !selected ? selectedIds : [selected]) : selected ? [selected] : []));
164
+ let anchor: string | undefined;
165
+ type Mods = { shift?: boolean; mod?: boolean };
166
+ function rangeIds(a: string, b: string) {
167
+ const i = indexOf.get(a) ?? indexOf.get(b)!, j = indexOf.get(b)!;
168
+ return rows.slice(Math.min(i, j), Math.max(i, j) + 1).filter((r) => !r.node.disabled).map((r) => r.node.id);
169
+ }
170
+ function pick(n: TreeNode, m: Mods = {}) {
84
171
  if (n.disabled) return;
85
- selected = n.id;
172
+ if (multiple) {
173
+ if (m.shift) selectedIds = rangeIds(anchor ?? selected ?? n.id, n.id);
174
+ else if (m.mod) {
175
+ const cur = [...selSet];
176
+ selectedIds = cur.includes(n.id) ? cur.filter((x) => x !== n.id) : [...cur, n.id];
177
+ anchor = n.id;
178
+ } else { selectedIds = [n.id]; anchor = n.id; }
179
+ selected = selectedIds.includes(n.id) ? n.id : selectedIds.at(-1);
180
+ } else selected = n.id;
86
181
  onselect?.(n);
87
182
  }
183
+ function selectAll() {
184
+ selectedIds = rows.filter((r) => !r.node.disabled).map((r) => r.node.id);
185
+ }
186
+
187
+ // ---------- activation
188
+ function activate(n: TreeNode) {
189
+ if (n.disabled) return;
190
+ // Enter on a node inside a multi-selection acts on that selection instead of collapsing it
191
+ if (!(multiple && selSet.has(n.id))) pick(n);
192
+ onactivate?.(n);
193
+ }
194
+
195
+ // ---------- node menu
196
+ let ctx = $state<{ x: number; y: number; items: MenuEntry[]; opener: HTMLElement } | null>(null);
197
+ let ctxAnchor = $state<HTMLElement>();
198
+ function openMenu(n: TreeNode, x: number, y: number, opener: HTMLElement) {
199
+ const items = nodeMenu?.(n) ?? [];
200
+ if (!items.length) return false; // nothing to offer: keep the browser's own menu
201
+ if (!selSet.has(n.id)) pick(n);
202
+ ctx = { x, y, items, opener };
203
+ return true;
204
+ }
205
+ function closeMenu(refocus: boolean) {
206
+ const o = ctx?.opener;
207
+ ctx = null;
208
+ if (refocus) o?.focus();
209
+ }
210
+ function onContext(e: MouseEvent, n: TreeNode) {
211
+ if (!nodeMenu || (e.target as Element).closest('.acts')) return;
212
+ if (openMenu(n, e.clientX, e.clientY, e.currentTarget as HTMLElement)) e.preventDefault();
213
+ }
214
+
215
+ // ---------- move: pointer drag + keyboard cut / paste
216
+ let live = $state('');
217
+ let flip = false;
218
+ function announce(msg: string) { live = msg + ((flip = !flip) ? '' : ' '); }
219
+ const nameOf = (list: TreeNode[]) => (list.length === 1 ? `"${list[0].label}"` : `${list.length} items`);
220
+ const rowOf = (id: string) => rows[indexOf.get(id)!];
221
+ function inside(id: string, ancestor: string) {
222
+ for (let r: Row | undefined = rowOf(id); r; r = r.parent ? rowOf(r.parent.id) : undefined) if (r.node.id === ancestor) return true;
223
+ return false;
224
+ }
225
+ /** Dragging a selection moves the whole selection; dragging an unselected node moves just it. */
226
+ function sourcesFor(id: string) {
227
+ const ids = multiple && selSet.has(id) ? rows.filter((r) => selSet.has(r.node.id) && !r.node.disabled).map((r) => r.node.id) : [id];
228
+ return ids.filter((x) => !ids.some((o) => o !== x && inside(x, o))); // a child travels with its dragged parent
229
+ }
230
+ function allowed(ids: string[], targetId: string, pos: DropPosition) {
231
+ if (!ids.length || !rowOf(targetId) || ids.some((s) => inside(targetId, s))) return false;
232
+ const t = rowOf(targetId).node;
233
+ if (t.disabled) return false;
234
+ return canDrop ? canDrop(ids.map((s) => rowOf(s).node), t, pos) !== false : true;
235
+ }
236
+ function drop(ids: string[], targetId: string, pos: DropPosition) {
237
+ const src = ids.map((s) => rowOf(s).node), t = rowOf(targetId).node;
238
+ ondrop?.(src, t, pos);
239
+ announce(`Moved ${nameOf(src)} ${pos === 'inside' ? 'into' : pos} "${t.label}"`);
240
+ }
241
+
242
+ let cut = $state<string[]>([]);
243
+ let drag = $state<{ ids: string[]; x: number; y: number; over?: { id: string; pos: DropPosition } } | null>(null);
244
+ let pending: { id: string; x: number; y: number } | null = null;
245
+ let suppressClick = false;
246
+
247
+ function pointerDown(e: PointerEvent, n: TreeNode) {
248
+ if (!draggable || e.button !== 0 || n.disabled || (e.target as Element).closest('.acts, .tw, .ck')) return;
249
+ pending = { id: n.id, x: e.clientX, y: e.clientY };
250
+ window.addEventListener('pointermove', pointerMove);
251
+ window.addEventListener('pointerup', pointerUp);
252
+ window.addEventListener('pointercancel', endDrag);
253
+ window.addEventListener('keydown', dragKey, true);
254
+ }
255
+ function pointerMove(e: PointerEvent) {
256
+ if (!pending) return;
257
+ if (!drag) {
258
+ if (Math.hypot(e.clientX - pending.x, e.clientY - pending.y) < 5) return;
259
+ drag = { ids: sourcesFor(pending.id), x: e.clientX, y: e.clientY };
260
+ }
261
+ let over: { id: string; pos: DropPosition } | undefined;
262
+ const li = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLElement>('li[role="treeitem"]');
263
+ if (li && root.contains(li) && li.dataset.id) {
264
+ const r = li.getBoundingClientRect(), f = (e.clientY - r.top) / r.height;
265
+ const pos: DropPosition = f < 0.25 ? 'before' : f > 0.75 ? 'after' : 'inside';
266
+ for (const p of [pos, f < 0.5 ? 'before' : 'after'] as DropPosition[]) // fall back to the nearest edge
267
+ if (allowed(drag.ids, li.dataset.id, p)) { over = { id: li.dataset.id, pos: p }; break; }
268
+ }
269
+ drag = { ...drag, x: e.clientX, y: e.clientY, over };
270
+ if (scroller) { // edge auto-scroll
271
+ const r = scroller.getBoundingClientRect();
272
+ if (e.clientY < r.top + 24) scroller.scrollTop -= 12;
273
+ else if (e.clientY > r.bottom - 24) scroller.scrollTop += 12;
274
+ }
275
+ }
276
+ function swallowClick() { suppressClick = true; setTimeout(() => (suppressClick = false)); }
277
+ function pointerUp() {
278
+ const d = drag;
279
+ if (d) swallowClick();
280
+ endDrag();
281
+ if (d?.over) drop(d.ids, d.over.id, d.over.pos);
282
+ }
283
+ function dragKey(e: KeyboardEvent) {
284
+ if (e.key === 'Escape' && drag) { e.preventDefault(); e.stopPropagation(); swallowClick(); endDrag(); announce('Move cancelled'); }
285
+ }
286
+ function endDrag() {
287
+ pending = null;
288
+ drag = null;
289
+ window.removeEventListener('pointermove', pointerMove);
290
+ window.removeEventListener('pointerup', pointerUp);
291
+ window.removeEventListener('pointercancel', endDrag);
292
+ window.removeEventListener('keydown', dragKey, true);
293
+ }
294
+ $effect(() => endDrag);
295
+
296
+ function pasteOn(n: TreeNode, pos: DropPosition) {
297
+ if (!cut.length) return;
298
+ const ids = cut.filter((s) => rowOf(s));
299
+ if (!allowed(ids, n.id, pos)) { announce(`Cannot move ${nameOf(ids.map((s) => rowOf(s).node))} here`); return; }
300
+ cut = [];
301
+ drop(ids, n.id, pos);
302
+ }
303
+
88
304
  function onKey(e: KeyboardEvent, i: number) {
89
305
  const r = rows[i], n = r.node;
90
306
  if (e.target !== e.currentTarget) return;
91
- if (e.key === 'ArrowDown') rows[i + 1] && focusRow(rows[i + 1].node.id);
92
- else if (e.key === 'ArrowUp') rows[i - 1] && focusRow(rows[i - 1].node.id);
93
- else if (e.key === 'Home') focusRow(rows[0].node.id);
94
- else if (e.key === 'End') focusRow(rows.at(-1)!.node.id);
307
+ const mod = e.ctrlKey || e.metaKey;
308
+ const go = (j: number) => {
309
+ const t = rows[Math.max(0, Math.min(rows.length - 1, j))];
310
+ if (!t) return;
311
+ focusRow(t.node.id);
312
+ if (multiple && e.shiftKey && !t.node.disabled) {
313
+ anchor ??= n.id;
314
+ selectedIds = rangeIds(anchor, t.node.id);
315
+ selected = t.node.id;
316
+ onselect?.(t.node);
317
+ }
318
+ };
319
+ const page = Math.max(1, Math.floor((viewH || 280) / rowH) - 1);
320
+ if (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10')) {
321
+ if (!nodeMenu) return;
322
+ const b = (e.currentTarget as HTMLElement).getBoundingClientRect();
323
+ if (!openMenu(n, b.left + 24, b.top + b.height / 2, e.currentTarget as HTMLElement)) return;
324
+ }
325
+ else if (draggable && mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'x') {
326
+ if (n.disabled) return;
327
+ cut = sourcesFor(n.id);
328
+ announce(`${nameOf(cut.map((s) => rowOf(s).node))} cut. Move to a node and press Control V to move inside it.`);
329
+ }
330
+ else if (draggable && mod && e.key.toLowerCase() === 'v' && cut.length) pasteOn(n, e.altKey ? 'before' : e.shiftKey ? 'after' : 'inside');
331
+ else if (draggable && e.key === 'Escape' && cut.length) { cut = []; announce('Cut cancelled'); }
332
+ else if (multiple && mod && !e.shiftKey && e.key.toLowerCase() === 'a') selectAll();
333
+ else if (e.key === 'ArrowDown') go(i + 1);
334
+ else if (e.key === 'ArrowUp') go(i - 1);
335
+ else if (e.key === 'PageDown') go(i + page);
336
+ else if (e.key === 'PageUp') go(i - page);
337
+ else if (e.key === 'Home') go(0);
338
+ else if (e.key === 'End') go(rows.length - 1);
95
339
  else if (e.key === 'ArrowRight') {
96
- if (expandable(n) && !expanded.includes(n.id)) expand(n);
97
- else if (expanded.includes(n.id) && rows[i + 1]?.parent === n) focusRow(rows[i + 1].node.id);
340
+ if (expandable(n) && !openSet.has(n.id)) expand(n);
341
+ else if (openSet.has(n.id) && rows[i + 1]?.parent === n) focusRow(rows[i + 1].node.id);
98
342
  } else if (e.key === 'ArrowLeft') {
99
- if (expanded.includes(n.id)) collapse(n);
343
+ if (openSet.has(n.id)) collapse(n);
100
344
  else if (r.parent) focusRow(r.parent.id);
101
- } else if (e.key === 'Enter') pick(n);
102
- else if (e.key === ' ') { checkable ? toggleCheck(n) : pick(n); }
345
+ } else if (e.key === 'Enter') activate(n);
346
+ else if (e.key === ' ') {
347
+ if (checkable) toggleCheck(n);
348
+ else if (multiple) pick(n, { mod: true });
349
+ else pick(n);
350
+ }
103
351
  else return;
104
352
  e.preventDefault();
105
353
  }
106
354
  </script>
107
355
 
108
- <ul class="tree" role="tree" aria-label={label} aria-multiselectable={checkable || undefined} bind:this={root}>
109
- {#each rows as r, i (r.node.id)}
110
- {@const n = r.node}
111
- <!-- svelte-ignore a11y_click_events_have_key_events -->
112
- <li
113
- role="treeitem" data-id={n.id} aria-level={r.depth + 1} aria-setsize={r.size} aria-posinset={r.pos}
114
- aria-expanded={expandable(n) ? expanded.includes(n.id) : undefined} aria-selected={selected === n.id}
115
- aria-disabled={n.disabled} tabindex={n.id === tabbable ? 0 : -1} data-selected={selected === n.id}
116
- style:padding-inline-start="{r.depth * 18 + 4}px"
117
- onkeydown={(e) => onKey(e, i)} onfocus={() => (focusId = n.id)} onclick={() => { focusId = n.id; pick(n); }} ondblclick={() => expandable(n) && toggle(n)}
356
+ <div class="wrap" style:--tr-tree-row-h={rowHeight != null ? `${rowHeight}px` : undefined}>
357
+ <span class="probe" bind:this={probe} aria-hidden="true"></span>
358
+ <div
359
+ class="scroll" data-scroll={heightCss ? '' : undefined} style:height={heightCss} bind:this={scroller} bind:clientHeight={viewH}
360
+ onscroll={(e) => { if (virtual) scrollTop = e.currentTarget.scrollTop; }}
361
+ >
362
+ <ul
363
+ class="tree" role="tree" aria-label={label} aria-multiselectable={multiple || checkable || undefined} bind:this={root}
364
+ data-virtual={virtual || undefined} data-dragging={drag ? '' : undefined}
365
+ style:padding-block-start={virtual ? `${start * rowH}px` : undefined}
366
+ style:padding-block-end={virtual ? `${(rows.length - end) * rowH}px` : undefined}
118
367
  >
119
- {#if expandable(n)}
120
- <button type="button" class="tw" tabindex="-1" aria-hidden="true" onclick={(e) => { e.stopPropagation(); toggle(n); }}>
121
- {#if loading.includes(n.id)}<Spinner size={14} />{:else}<Icon name={expanded.includes(n.id) ? 'chevron-down' : 'chevron-right'} />{/if}
122
- </button>
123
- {:else}<span class="tw"></span>{/if}
124
- {#if checkable}
125
- <span class="ck" onclick={(e) => e.stopPropagation()} role="presentation">
126
- <Checkbox tabindex={-1} aria-label="Select {n.label}" disabled={n.disabled} checked={checkState(n) === 'all'} indeterminate={checkState(n) === 'some'} onchange={() => toggleCheck(n)} />
127
- </span>
128
- {/if}
129
- {#if n.icon}<Icon name={n.icon} />{:else if expandable(n) || kids(n)}<Icon name="folder" />{:else}<Icon name="file" />{/if}
130
- <span class="lbl">{n.label}</span>
131
- </li>
132
- {/each}
133
- </ul>
368
+ {#each shown as r, k (r.node.id)}
369
+ {@const n = r.node}
370
+ {@const i = start + k}
371
+ {@const sel = selSet.has(n.id)}
372
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
373
+ <li
374
+ role="treeitem" data-id={n.id} aria-level={r.depth + 1} aria-setsize={r.size} aria-posinset={r.pos}
375
+ aria-expanded={expandable(n) ? openSet.has(n.id) : undefined} aria-selected={sel}
376
+ aria-disabled={n.disabled} tabindex={n.id === tabbable ? 0 : -1} data-selected={sel}
377
+ data-cut={cut.includes(n.id) || (drag?.ids.includes(n.id) ?? false) || undefined}
378
+ data-drop={drag?.over?.id === n.id ? drag.over.pos : undefined}
379
+ style:padding-inline-start="{r.depth * 18 + 4}px"
380
+ onkeydown={(e) => onKey(e, i)} onfocus={() => (focusId = n.id)}
381
+ onclick={(e) => { if (suppressClick) return; focusId = n.id; pick(n, { shift: e.shiftKey, mod: e.ctrlKey || e.metaKey }); }}
382
+ ondblclick={() => { if (expandable(n)) toggle(n); activate(n); }}
383
+ oncontextmenu={(e) => onContext(e, n)}
384
+ onpointerdown={(e) => pointerDown(e, n)}
385
+ >
386
+ {#if expandable(n)}
387
+ <button type="button" class="tw" tabindex="-1" aria-hidden="true" onclick={(e) => { e.stopPropagation(); toggle(n); }}>
388
+ {#if loading.includes(n.id)}<Spinner size={14} />{:else}<Icon name={openSet.has(n.id) ? 'chevron-down' : 'chevron-right'} />{/if}
389
+ </button>
390
+ {:else}<span class="tw"></span>{/if}
391
+ {#if checkable}
392
+ <span class="ck" onclick={(e) => e.stopPropagation()} role="presentation">
393
+ <Checkbox tabindex={-1} aria-label="Select {n.label}" disabled={n.disabled} checked={checkState(n) === 'all'} indeterminate={checkState(n) === 'some'} onchange={() => toggleCheck(n)} />
394
+ </span>
395
+ {/if}
396
+ {#if n.icon}<Icon name={n.icon} />{:else if expandable(n) || kids(n)}<Icon name="folder" />{:else}<Icon name="file" />{/if}
397
+ <span class="lbl">{n.label}</span>
398
+ {#if nodeActions && !n.disabled}
399
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
400
+ <span class="acts" onclick={(e) => e.stopPropagation()} ondblclick={(e) => e.stopPropagation()} onpointerdown={(e) => e.stopPropagation()}>{@render nodeActions(n)}</span>
401
+ {/if}
402
+ </li>
403
+ {/each}
404
+ </ul>
405
+ </div>
406
+ <div class="sr" role="status" aria-live="polite">{live}</div>
407
+ {#if drag}<div class="ghost" aria-hidden="true" style:left="{drag.x + 12}px" style:top="{drag.y + 12}px">{drag.ids.length === 1 ? rowOf(drag.ids[0])?.node.label : `${drag.ids.length} items`}</div>{/if}
408
+ </div>
409
+
410
+ {#if ctx}
411
+ <div class="ctxpt" bind:this={ctxAnchor} style:left="{ctx.x}px" style:top="{ctx.y}px" aria-hidden="true"></div>
412
+ {#if ctxAnchor}
413
+ <Popover open={true} fixed anchor={ctxAnchor} onclose={closeMenu}>
414
+ <Menu items={ctx.items} label="Node actions" autofocus onclose={() => closeMenu(true)} />
415
+ </Popover>
416
+ {/if}
417
+ {/if}
134
418
 
135
419
  <style>
420
+ .wrap { position: relative; }
421
+ .probe { position: absolute; width: 0; height: var(--tr-tree-row-h); visibility: hidden; pointer-events: none; }
422
+ .scroll[data-scroll] { overflow: auto; }
136
423
  .tree { margin: 0; padding: 4px; list-style: none; }
137
- li { display: flex; align-items: center; gap: 6px; height: 28px; padding-inline-end: 8px; border-radius: var(--tr-radius-sm); color: var(--tr-on-surface); cursor: default; outline: none; }
424
+ .tree[data-virtual] { padding-block: 0; }
425
+ li { position: relative; display: flex; align-items: center; gap: 6px; height: var(--tr-tree-row-h); box-sizing: border-box; padding-inline-end: 8px; border-radius: var(--tr-radius-sm); color: var(--tr-on-surface); cursor: default; outline: none; user-select: none; }
138
426
  li:hover { background: var(--tr-surface-container-high); }
139
427
  li[data-selected='true'] { background: color-mix(in srgb, var(--tr-primary-container) 60%, transparent); box-shadow: inset 3px 0 0 var(--tr-primary); }
140
428
  li:focus-visible { box-shadow: inset 0 0 0 2px var(--tr-primary); }
141
429
  li[aria-disabled='true'] { opacity: 0.45; }
430
+ li[data-cut] { opacity: 0.5; }
431
+ li[data-drop='inside'] { outline: 2px solid var(--tr-primary); outline-offset: -2px; background: color-mix(in srgb, var(--tr-primary-container) 40%, transparent); }
432
+ li[data-drop='before']::after, li[data-drop='after']::after { content: ''; position: absolute; inset-inline: 0; height: 2px; background: var(--tr-primary); pointer-events: none; }
433
+ li[data-drop='before']::after { top: -1px; }
434
+ li[data-drop='after']::after { bottom: -1px; }
142
435
  .tw { display: inline-grid; place-items: center; flex: none; width: 20px; height: 20px; padding: 0; border: 0; border-radius: var(--tr-radius-sm); background: none; color: var(--tr-on-surface-variant); cursor: pointer; }
143
436
  button.tw:hover { background: var(--tr-surface-container-highest); }
144
437
  .ck { display: inline-flex; }
145
438
  li > :global(svg) { color: var(--tr-on-surface-variant); }
146
439
  .lbl { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
440
+ /* hidden actions are also unfocusable, so Tab only enters the actions of the focused / hovered row */
441
+ .acts { display: inline-flex; flex: none; align-items: center; gap: 2px; visibility: hidden; }
442
+ li:hover .acts, li:focus-within .acts { visibility: visible; }
443
+ .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
444
+ .ghost { position: fixed; z-index: 400; pointer-events: none; padding: 2px 10px; border-radius: var(--tr-radius-sm); background: var(--tr-surface-container-highest); color: var(--tr-on-surface); box-shadow: 0 2px 8px rgb(0 0 0 / 0.3); font-size: 0.85em; white-space: nowrap; }
445
+ .ctxpt { position: fixed; width: 0; height: 0; pointer-events: none; }
147
446
  </style>
@@ -1,8 +1,15 @@
1
- import type { TreeNode } from '../types.js';
1
+ import { type Snippet } from 'svelte';
2
+ import type { TreeNode, MenuEntry } from '../types.js';
3
+ type DropPosition = 'before' | 'after' | 'inside';
2
4
  type $$ComponentProps = {
3
5
  nodes: TreeNode[];
4
6
  expanded?: string[];
7
+ /** The single selected id, or with `multiple` the most recently picked ("lead") node. */
5
8
  selected?: string;
9
+ /** Multi-select: click, Ctrl/Cmd+click toggles, Shift+click or Shift+Arrow selects a range, Ctrl+A selects all visible nodes. */
10
+ multiple?: boolean;
11
+ /** With `multiple`: the selected ids. */
12
+ selectedIds?: string[];
6
13
  /** Tri-state checkboxes. `checked` holds leaf ids. */
7
14
  checkable?: boolean;
8
15
  checked?: string[];
@@ -10,7 +17,27 @@ type $$ComponentProps = {
10
17
  loadChildren?: (node: TreeNode) => Promise<TreeNode[]>;
11
18
  label?: string;
12
19
  onselect?: (node: TreeNode) => void;
20
+ /** Enter or double-click on an enabled node ("open"). A double-click still toggles an expandable node as well. */
21
+ onactivate?: (node: TreeNode) => void;
22
+ /** Context menu for a node: right-click, the Menu key or Shift+F10. Return an empty list to keep the browser's menu. */
23
+ nodeMenu?: (node: TreeNode) => MenuEntry[];
24
+ /** Trailing actions, revealed on hover and when the row is focused (Tab moves from the row into them). */
25
+ nodeActions?: Snippet<[node: TreeNode]>;
26
+ /** Render only the rows in view (needs `height`; scrolls inside the tree). */
27
+ virtual?: boolean;
28
+ /** Height of the scrolling area (CSS length or px number). Also enables scrolling without `virtual`. */
29
+ height?: string | number;
30
+ /** Row height in px (default: the `--tr-tree-row-h` density token). */
31
+ rowHeight?: number;
32
+ /** Extra rows rendered above and below the visible window. */
33
+ overscan?: number;
34
+ /** Move nodes by pointer drag, or by keyboard: Ctrl+X to cut, then Ctrl+V (inside), Ctrl+Shift+V (after) or Ctrl+Alt+V (before) on the target. */
35
+ draggable?: boolean;
36
+ /** Veto a drop. A node is never offered as a target of itself or its descendants. */
37
+ canDrop?: (sources: TreeNode[], target: TreeNode, position: DropPosition) => boolean;
38
+ /** The tree does not rearrange `nodes`: update your data here. */
39
+ ondrop?: (sources: TreeNode[], target: TreeNode, position: DropPosition) => void;
13
40
  };
14
- declare const Tree: import("svelte").Component<$$ComponentProps, {}, "checked" | "expanded" | "selected">;
41
+ declare const Tree: import("svelte").Component<$$ComponentProps, {}, "checked" | "expanded" | "selected" | "selectedIds">;
15
42
  type Tree = ReturnType<typeof Tree>;
16
43
  export default Tree;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure layout engine behind GridLayout: integer grid cells, collision resolution, vertical compaction,
3
+ * size clamping and responsive column mapping. Nothing here touches the DOM and no function mutates its input.
4
+ */
5
+ export type GridItem = {
6
+ id: string;
7
+ /** Column of the left edge (0-based). */
8
+ x: number;
9
+ /** Row of the top edge (0-based). */
10
+ y: number;
11
+ /** Width in columns. */
12
+ w: number;
13
+ /** Height in rows. */
14
+ h: number;
15
+ minW?: number;
16
+ minH?: number;
17
+ maxW?: number;
18
+ maxH?: number;
19
+ /** Never moved or resized, and other tiles flow around it. */
20
+ static?: boolean;
21
+ };
22
+ /** Whether two items overlap (touching edges do not count). */
23
+ export declare function collides(a: GridItem, b: GridItem): boolean;
24
+ /** Number of rows the layout occupies. */
25
+ export declare function bottom(items: GridItem[]): number;
26
+ /** Clamp one item into `cols` columns: size within its min/max (and the grid), position inside the grid. */
27
+ export declare function clampItem(item: GridItem, cols: number): GridItem;
28
+ /** Clamp every item and compact the layout vertically. */
29
+ export declare function compact(items: GridItem[], cols: number): GridItem[];
30
+ /** Fit a layout to a (possibly narrower) column count: clamp, resolve collisions, compact. */
31
+ export declare const fitToCols: typeof compact;
32
+ /** Move an item to (x, y): clamped to the grid, colliding items are pushed down, the rest compact around it. */
33
+ export declare function moveItem(items: GridItem[], id: string, x: number, y: number, cols: number): GridItem[];
34
+ /** Resize an item to w by h cells, within its min/max and the grid edge; colliding items are pushed down. */
35
+ export declare function resizeItem(items: GridItem[], id: string, w: number, h: number, cols: number): GridItem[];
36
+ /**
37
+ * Columns for a container width. `breakpoints` maps a minimum width in px to a column count; the largest
38
+ * key not above `width` wins, and a width below every key uses the smallest key. Without breakpoints it is `fallback`.
39
+ */
40
+ export declare function colsFor(width: number, breakpoints: Record<number, number> | undefined, fallback: number): number;
41
+ /** Whether two layouts place every item identically. */
42
+ export declare function sameLayout(a: GridItem[], b: GridItem[]): boolean;