@joewinke/jatui 0.39.0 → 0.41.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/CLAUDE.md +52 -5
- package/THIRD_PARTY_NOTICES.md +31 -0
- package/dist/actions/scrollEdge.d.ts +55 -0
- package/dist/actions/scrollEdge.js +144 -0
- package/dist/artifact-blocks/blocks/diagram/DiagramBlock.svelte.d.ts +1 -1
- package/dist/artifact-blocks/blocks/diagram/schema.d.ts +1 -1
- package/dist/components/AnimatedNumber.svelte +42 -1
- package/dist/components/AnimatedNumber.svelte.d.ts +7 -0
- package/dist/components/BottomSheet.svelte +7 -6
- package/dist/components/BottomSheet.svelte.d.ts +2 -0
- package/dist/components/CloseButton.svelte +75 -0
- package/dist/components/CloseButton.svelte.d.ts +29 -0
- package/dist/components/Drawer.svelte +10 -11
- package/dist/components/Drawer.svelte.d.ts +2 -8
- package/dist/components/IconButton.svelte +200 -0
- package/dist/components/IconButton.svelte.d.ts +46 -0
- package/dist/components/LinkButton.svelte +117 -0
- package/dist/components/LinkButton.svelte.d.ts +32 -0
- package/dist/components/MediaLightbox.svelte +2 -7
- package/dist/components/Modal.svelte +2 -10
- package/dist/components/PinInput.svelte +94 -31
- package/dist/components/PinInput.svelte.d.ts +4 -0
- package/dist/components/SegmentedControl.svelte +263 -0
- package/dist/components/SegmentedControl.svelte.d.ts +48 -0
- package/dist/components/TextArea.svelte +67 -5
- package/dist/components/TextArea.svelte.d.ts +4 -6
- package/dist/components/ThemeSelector.svelte +29 -9
- package/dist/components/ThemeSelector.svelte.d.ts +6 -0
- package/dist/components/ThemeToggle.svelte +28 -3
- package/dist/components/ThemeToggle.svelte.d.ts +9 -0
- package/dist/components/basic/Breadcrumb.svelte +226 -11
- package/dist/components/basic/Breadcrumb.svelte.d.ts +27 -1
- package/dist/components/basic/breadcrumbCollapse.d.ts +19 -0
- package/dist/components/basic/breadcrumbCollapse.js +12 -0
- package/dist/components/graph/GraphCanvas.svelte.d.ts +2 -2
- package/dist/components/settings/SettingsRow.svelte +177 -0
- package/dist/components/settings/SettingsRow.svelte.d.ts +32 -0
- package/dist/components/settings/SettingsSection.svelte +139 -0
- package/dist/components/settings/SettingsSection.svelte.d.ts +21 -0
- package/dist/components/stats/ActivityHeatmap.svelte +412 -0
- package/dist/components/stats/ActivityHeatmap.svelte.d.ts +72 -0
- package/dist/components/stats/BarList.svelte +262 -0
- package/dist/components/stats/BarList.svelte.d.ts +57 -0
- package/dist/components/stats/ChartCard.svelte +230 -0
- package/dist/components/stats/ChartCard.svelte.d.ts +63 -0
- package/dist/components/stats/StatCard.svelte +387 -0
- package/dist/components/stats/StatCard.svelte.d.ts +37 -0
- package/dist/components/stats/StatGroup.svelte +67 -0
- package/dist/components/stats/StatGroup.svelte.d.ts +14 -0
- package/dist/components/stats/heatmap.d.ts +76 -0
- package/dist/components/stats/heatmap.js +151 -0
- package/dist/components/tree/TreeView.svelte +680 -0
- package/dist/components/tree/TreeView.svelte.d.ts +123 -0
- package/dist/components/tree/flattenTree.d.ts +57 -0
- package/dist/components/tree/flattenTree.js +91 -0
- package/dist/components/tree/treeKeys.d.ts +46 -0
- package/dist/components/tree/treeKeys.js +80 -0
- package/dist/data/grid/cells.d.ts +40 -0
- package/dist/data/grid/cells.js +89 -0
- package/dist/data/grid/clipboard.d.ts +46 -0
- package/dist/data/grid/clipboard.js +131 -0
- package/dist/data/grid/edits.d.ts +148 -0
- package/dist/data/grid/edits.js +222 -0
- package/dist/data/grid/index.d.ts +12 -0
- package/dist/data/grid/index.js +12 -0
- package/dist/index.d.ts +36 -1
- package/dist/index.js +31 -1
- package/dist/styles/tokens.css +80 -1
- package/dist/utils/pinInput.d.ts +33 -0
- package/dist/utils/pinInput.js +63 -0
- package/dist/utils/textareaRows.d.ts +23 -0
- package/dist/utils/textareaRows.js +13 -0
- package/dist/utils/themeWipe.d.ts +36 -17
- package/dist/utils/themeWipe.js +145 -35
- package/package.json +5 -1
|
@@ -0,0 +1,680 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
TreeView: data-driven tree with connector guides, the full WAI-ARIA tree
|
|
3
|
+
keyboard model, typeahead, single/multi selection and optional windowing
|
|
4
|
+
(BoardUI wave 3, jatui-6kitr).
|
|
5
|
+
|
|
6
|
+
Rendered as ONE flat list of role="treeitem" rows (aria-level / posinset /
|
|
7
|
+
setsize) produced by `flattenTree`, so it can be windowed with
|
|
8
|
+
`jatui/virtual`. The guide algorithm (trunks only while an ancestor has a
|
|
9
|
+
later sibling, elbow + tail + bridge) and the "ArrowRight on an open branch
|
|
10
|
+
steps into its first child" rule follow BoardUI's tree view (MIT, © 2026
|
|
11
|
+
Mertcan Dundar Esmergul); this is a clean Svelte re-implementation.
|
|
12
|
+
-->
|
|
13
|
+
<script lang="ts" module>
|
|
14
|
+
export interface TreeRowContext {
|
|
15
|
+
id: string;
|
|
16
|
+
level: number;
|
|
17
|
+
expanded: boolean;
|
|
18
|
+
hasChildren: boolean;
|
|
19
|
+
selected: boolean;
|
|
20
|
+
focused: boolean;
|
|
21
|
+
disabled: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Pass `createRowWindow` from `jatui/virtual`; TreeView never imports it itself. */
|
|
25
|
+
export interface TreeViewVirtualOptions {
|
|
26
|
+
createRowWindow: (opts: {
|
|
27
|
+
count: () => number;
|
|
28
|
+
getKey: (index: number) => string;
|
|
29
|
+
estimateSize: (index: number) => number;
|
|
30
|
+
overscan?: number;
|
|
31
|
+
threshold?: number;
|
|
32
|
+
scrollRoot?: HTMLElement | Window | 'auto';
|
|
33
|
+
}) => {
|
|
34
|
+
readonly items: ReadonlyArray<{ index: number; key: string; gapBefore: number }>;
|
|
35
|
+
readonly paddingTop: number;
|
|
36
|
+
readonly paddingBottom: number;
|
|
37
|
+
readonly windowed: boolean;
|
|
38
|
+
measure: (node: HTMLElement, key: string) => { update?(key: string): void; destroy(): void };
|
|
39
|
+
anchor: (node: HTMLElement) => { destroy(): void };
|
|
40
|
+
scrollToKey(key: string, opts?: { align?: 'auto' | 'start' | 'center' | 'end' }): void;
|
|
41
|
+
};
|
|
42
|
+
/** Row height estimate in px (default: the size's row height). */
|
|
43
|
+
estimateRowSize?: number;
|
|
44
|
+
overscan?: number;
|
|
45
|
+
/** Render everything below this many visible rows (default 0 = always window). */
|
|
46
|
+
threshold?: number;
|
|
47
|
+
scrollRoot?: HTMLElement | Window | 'auto';
|
|
48
|
+
}
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<script lang="ts" generics="T">
|
|
52
|
+
import { tick, untrack, type Snippet } from 'svelte';
|
|
53
|
+
import { flattenTree, collectBranchIds, pathToNode, type FlatTreeNode } from './flattenTree';
|
|
54
|
+
import { resolveTreeKey, treeTypeahead } from './treeKeys';
|
|
55
|
+
import { motionSlide } from '../../motion/transitions';
|
|
56
|
+
|
|
57
|
+
interface Props {
|
|
58
|
+
nodes: readonly T[];
|
|
59
|
+
/** Default: `node.id`. */
|
|
60
|
+
getId?: (node: T) => string;
|
|
61
|
+
/** Default: `node.children`. */
|
|
62
|
+
getChildren?: (node: T) => readonly T[] | null | undefined;
|
|
63
|
+
/** Text for the row and typeahead. Default: `node.label ?? node.name ?? id`. */
|
|
64
|
+
getLabel?: (node: T) => string;
|
|
65
|
+
/** Branch override for lazily-loaded folders (show a chevron before children arrive). */
|
|
66
|
+
hasChildren?: (node: T) => boolean;
|
|
67
|
+
isDisabled?: (node: T) => boolean;
|
|
68
|
+
/** Open branch ids. Replaced (never mutated) on change. */
|
|
69
|
+
expanded?: Set<string>;
|
|
70
|
+
/** Selected ids. Replaced (never mutated) on change. */
|
|
71
|
+
selected?: Set<string>;
|
|
72
|
+
selectionMode?: 'none' | 'single' | 'multiple';
|
|
73
|
+
size?: 'sm' | 'md';
|
|
74
|
+
showGuides?: boolean;
|
|
75
|
+
/** Default folder/file icons. False hides them (an `icon` snippet still wins). */
|
|
76
|
+
icons?: boolean;
|
|
77
|
+
/** Clicking a branch row (not just its chevron) toggles it. */
|
|
78
|
+
expandOnClick?: boolean;
|
|
79
|
+
/** Expand/collapse animation. Always off under reduced motion and while windowed. */
|
|
80
|
+
animate?: boolean;
|
|
81
|
+
/** CSS max-height; makes the tree its own scroller. */
|
|
82
|
+
maxHeight?: string;
|
|
83
|
+
ariaLabel?: string;
|
|
84
|
+
emptyText?: string;
|
|
85
|
+
virtual?: TreeViewVirtualOptions;
|
|
86
|
+
label?: Snippet<[T, TreeRowContext]>;
|
|
87
|
+
icon?: Snippet<[T, TreeRowContext]>;
|
|
88
|
+
trailing?: Snippet<[T, TreeRowContext]>;
|
|
89
|
+
empty?: Snippet;
|
|
90
|
+
onExpandedChange?: (expanded: Set<string>) => void;
|
|
91
|
+
onSelectionChange?: (selected: Set<string>) => void;
|
|
92
|
+
/** A branch just opened: load its children here if they are lazy. */
|
|
93
|
+
onExpand?: (node: T) => void;
|
|
94
|
+
/** Enter or double-click. */
|
|
95
|
+
onAction?: (node: T) => void;
|
|
96
|
+
onRowContextMenu?: (node: T, event: MouseEvent) => void;
|
|
97
|
+
class?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let {
|
|
101
|
+
nodes,
|
|
102
|
+
getId = (n: T) => String((n as { id?: unknown }).id),
|
|
103
|
+
getChildren = (n: T) => (n as { children?: T[] }).children,
|
|
104
|
+
getLabel,
|
|
105
|
+
hasChildren,
|
|
106
|
+
isDisabled,
|
|
107
|
+
expanded = $bindable(new Set<string>()),
|
|
108
|
+
selected = $bindable(new Set<string>()),
|
|
109
|
+
selectionMode = 'single',
|
|
110
|
+
size = 'md',
|
|
111
|
+
showGuides = true,
|
|
112
|
+
icons = true,
|
|
113
|
+
expandOnClick = false,
|
|
114
|
+
animate = true,
|
|
115
|
+
maxHeight,
|
|
116
|
+
ariaLabel = 'Tree',
|
|
117
|
+
emptyText = 'Nothing here',
|
|
118
|
+
virtual,
|
|
119
|
+
label,
|
|
120
|
+
icon,
|
|
121
|
+
trailing,
|
|
122
|
+
empty,
|
|
123
|
+
onExpandedChange,
|
|
124
|
+
onSelectionChange,
|
|
125
|
+
onExpand,
|
|
126
|
+
onAction,
|
|
127
|
+
onRowContextMenu,
|
|
128
|
+
class: className = ''
|
|
129
|
+
}: Props = $props();
|
|
130
|
+
|
|
131
|
+
const uid = $props.id();
|
|
132
|
+
|
|
133
|
+
const labelOf = (n: T, id: string) => {
|
|
134
|
+
if (getLabel) return getLabel(n);
|
|
135
|
+
const o = n as { label?: unknown; name?: unknown };
|
|
136
|
+
return String(o.label ?? o.name ?? id);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const rows = $derived(
|
|
140
|
+
flattenTree(nodes, { getId, getChildren, expanded, hasChildren })
|
|
141
|
+
);
|
|
142
|
+
const indexById = $derived(new Map(rows.map((r, i) => [r.id, i])));
|
|
143
|
+
const disabledIds = $derived(
|
|
144
|
+
isDisabled ? new Set(rows.filter((r) => isDisabled(r.node)).map((r) => r.id)) : new Set<string>()
|
|
145
|
+
);
|
|
146
|
+
const disabled = (id: string) => disabledIds.has(id);
|
|
147
|
+
|
|
148
|
+
let focusedId = $state<string | null>(null);
|
|
149
|
+
let anchorId: string | null = null;
|
|
150
|
+
|
|
151
|
+
const tabStopId = $derived.by(() => {
|
|
152
|
+
if (focusedId != null && indexById.has(focusedId)) return focusedId;
|
|
153
|
+
for (const r of rows) if (selected.has(r.id) && !disabled(r.id)) return r.id;
|
|
154
|
+
return rows.find((r) => !disabled(r.id))?.id ?? null;
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Stable DOM ids for arbitrary node ids.
|
|
158
|
+
const domIds = new Map<string, string>();
|
|
159
|
+
function domId(id: string): string {
|
|
160
|
+
let d = domIds.get(id);
|
|
161
|
+
if (!d) {
|
|
162
|
+
d = `${uid}-r${domIds.size}`;
|
|
163
|
+
domIds.set(id, d);
|
|
164
|
+
}
|
|
165
|
+
return d;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Windowing ───────────────────────────────────────────────────────────
|
|
169
|
+
const rowHeight = $derived(size === 'sm' ? 28 : 32);
|
|
170
|
+
const virtualAtMount = untrack(() => virtual);
|
|
171
|
+
const win = virtualAtMount
|
|
172
|
+
? virtualAtMount.createRowWindow({
|
|
173
|
+
count: () => rows.length,
|
|
174
|
+
getKey: (i) => rows[i]?.id ?? String(i),
|
|
175
|
+
estimateSize: () => virtual?.estimateRowSize ?? rowHeight,
|
|
176
|
+
overscan: virtualAtMount.overscan,
|
|
177
|
+
get threshold() {
|
|
178
|
+
return virtual?.threshold ?? 0;
|
|
179
|
+
},
|
|
180
|
+
scrollRoot: virtualAtMount.scrollRoot ?? 'auto'
|
|
181
|
+
})
|
|
182
|
+
: null;
|
|
183
|
+
const noop = (_n: HTMLElement, _k?: string) => ({ update() {}, destroy() {} });
|
|
184
|
+
const measureAction = win ? win.measure : noop;
|
|
185
|
+
const anchorAction = win ? win.anchor : noop;
|
|
186
|
+
|
|
187
|
+
type Slot = { row: FlatTreeNode<T>; gap: number; bridge: boolean };
|
|
188
|
+
const slots = $derived.by((): Slot[] => {
|
|
189
|
+
const bridge = (i: number) => rows[i].expanded && rows[i + 1]?.parentId === rows[i].id;
|
|
190
|
+
if (!win) return rows.map((row, i) => ({ row, gap: 0, bridge: bridge(i) }));
|
|
191
|
+
return win.items
|
|
192
|
+
.filter((it) => rows[it.index])
|
|
193
|
+
.map((it) => ({ row: rows[it.index], gap: it.gapBefore, bridge: bridge(it.index) }));
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ── State changes ───────────────────────────────────────────────────────
|
|
197
|
+
function setExpanded(next: Set<string>) {
|
|
198
|
+
expanded = next;
|
|
199
|
+
onExpandedChange?.(next);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function expand(ids: string[]) {
|
|
203
|
+
const next = new Set(expanded);
|
|
204
|
+
const opened: T[] = [];
|
|
205
|
+
for (const id of ids) {
|
|
206
|
+
if (next.has(id)) continue;
|
|
207
|
+
next.add(id);
|
|
208
|
+
const r = rows[indexById.get(id) ?? -1];
|
|
209
|
+
if (r) opened.push(r.node);
|
|
210
|
+
}
|
|
211
|
+
if (next.size === expanded.size) return;
|
|
212
|
+
setExpanded(next);
|
|
213
|
+
for (const n of opened) onExpand?.(n);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function collapse(id: string) {
|
|
217
|
+
if (!expanded.has(id)) return;
|
|
218
|
+
const next = new Set(expanded);
|
|
219
|
+
next.delete(id);
|
|
220
|
+
setExpanded(next);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function toggle(row: FlatTreeNode<T>) {
|
|
224
|
+
if (!row.hasChildren || disabled(row.id)) return;
|
|
225
|
+
if (row.expanded) collapse(row.id);
|
|
226
|
+
else expand([row.id]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function setSelected(next: Set<string>) {
|
|
230
|
+
selected = next;
|
|
231
|
+
onSelectionChange?.(next);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function selectRow(id: string, mode: 'replace' | 'toggle' | 'range') {
|
|
235
|
+
if (selectionMode === 'none' || disabled(id)) return;
|
|
236
|
+
if (selectionMode === 'single' || mode === 'replace') {
|
|
237
|
+
if (selected.size === 1 && selected.has(id)) return;
|
|
238
|
+
setSelected(new Set([id]));
|
|
239
|
+
anchorId = id;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (mode === 'toggle') {
|
|
243
|
+
const next = new Set(selected);
|
|
244
|
+
if (next.has(id)) next.delete(id);
|
|
245
|
+
else next.add(id);
|
|
246
|
+
setSelected(next);
|
|
247
|
+
anchorId = id;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const a = indexById.get(anchorId ?? id) ?? indexById.get(id)!;
|
|
251
|
+
const b = indexById.get(id)!;
|
|
252
|
+
const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
253
|
+
const next = new Set<string>();
|
|
254
|
+
for (let i = lo; i <= hi; i++) if (!disabled(rows[i].id)) next.add(rows[i].id);
|
|
255
|
+
setSelected(next);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function focusRow(id: string) {
|
|
259
|
+
focusedId = id;
|
|
260
|
+
await tick();
|
|
261
|
+
let el = document.getElementById(domId(id));
|
|
262
|
+
if (!el && win) {
|
|
263
|
+
win.scrollToKey(id, { align: 'auto' });
|
|
264
|
+
await new Promise((r) => requestAnimationFrame(() => r(null)));
|
|
265
|
+
await tick();
|
|
266
|
+
el = document.getElementById(domId(id));
|
|
267
|
+
}
|
|
268
|
+
el?.focus();
|
|
269
|
+
if (!win) el?.scrollIntoView?.({ block: 'nearest' });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Pointer ─────────────────────────────────────────────────────────────
|
|
273
|
+
function onRowClick(e: MouseEvent, row: FlatTreeNode<T>) {
|
|
274
|
+
if (disabled(row.id)) return;
|
|
275
|
+
focusedId = row.id;
|
|
276
|
+
if (selectionMode !== 'none') {
|
|
277
|
+
if (e.shiftKey && selectionMode === 'multiple') selectRow(row.id, 'range');
|
|
278
|
+
else if ((e.metaKey || e.ctrlKey) && selectionMode === 'multiple') selectRow(row.id, 'toggle');
|
|
279
|
+
else selectRow(row.id, 'replace');
|
|
280
|
+
}
|
|
281
|
+
if (expandOnClick && row.hasChildren && !e.shiftKey && !e.metaKey && !e.ctrlKey) toggle(row);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function onChevronClick(e: MouseEvent, row: FlatTreeNode<T>) {
|
|
285
|
+
e.stopPropagation();
|
|
286
|
+
if (disabled(row.id)) return;
|
|
287
|
+
focusedId = row.id;
|
|
288
|
+
toggle(row);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── Keyboard ────────────────────────────────────────────────────────────
|
|
292
|
+
let typeBuffer = '';
|
|
293
|
+
let typeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
294
|
+
|
|
295
|
+
function onKeydown(e: KeyboardEvent) {
|
|
296
|
+
if (e.altKey || e.isComposing) return;
|
|
297
|
+
const current = focusedId != null && indexById.has(focusedId) ? focusedId : tabStopId;
|
|
298
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
299
|
+
|
|
300
|
+
// Typeahead: single printable characters, no modifiers.
|
|
301
|
+
if (!mod && e.key.length === 1 && e.key !== ' ' && e.key !== '*') {
|
|
302
|
+
typeBuffer += e.key;
|
|
303
|
+
clearTimeout(typeTimer);
|
|
304
|
+
typeTimer = setTimeout(() => (typeBuffer = ''), 500);
|
|
305
|
+
const from = current != null ? (indexById.get(current) ?? -1) : -1;
|
|
306
|
+
const j = treeTypeahead(
|
|
307
|
+
rows.map((r) => labelOf(r.node, r.id)),
|
|
308
|
+
from,
|
|
309
|
+
typeBuffer,
|
|
310
|
+
(i) => disabled(rows[i].id)
|
|
311
|
+
);
|
|
312
|
+
if (j >= 0) {
|
|
313
|
+
e.preventDefault();
|
|
314
|
+
void focusRow(rows[j].id);
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (mod && e.key !== 'a') return;
|
|
319
|
+
|
|
320
|
+
if (mod && e.key === 'a') {
|
|
321
|
+
if (selectionMode !== 'multiple') return;
|
|
322
|
+
e.preventDefault();
|
|
323
|
+
setSelected(new Set(rows.filter((r) => !disabled(r.id)).map((r) => r.id)));
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const res = resolveTreeKey(rows, current, e.key, disabled);
|
|
328
|
+
if (!res) return;
|
|
329
|
+
e.preventDefault();
|
|
330
|
+
switch (res.type) {
|
|
331
|
+
case 'focus':
|
|
332
|
+
void focusRow(res.id);
|
|
333
|
+
if (e.shiftKey && selectionMode === 'multiple' && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
|
334
|
+
if (anchorId == null && current) anchorId = current;
|
|
335
|
+
selectRow(res.id, 'range');
|
|
336
|
+
}
|
|
337
|
+
break;
|
|
338
|
+
case 'expand':
|
|
339
|
+
expand([res.id]);
|
|
340
|
+
break;
|
|
341
|
+
case 'expandMany':
|
|
342
|
+
expand(res.ids);
|
|
343
|
+
break;
|
|
344
|
+
case 'collapse':
|
|
345
|
+
collapse(res.id);
|
|
346
|
+
break;
|
|
347
|
+
case 'action': {
|
|
348
|
+
const r = rows[indexById.get(res.id)!];
|
|
349
|
+
if (onAction) onAction(r.node);
|
|
350
|
+
else if (r.hasChildren) toggle(r);
|
|
351
|
+
else selectRow(r.id, 'replace');
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
case 'select':
|
|
355
|
+
selectRow(res.id, selectionMode === 'multiple' ? 'toggle' : 'replace');
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Transitions: leaving rows go inert at once, never stay in the tab order ─
|
|
361
|
+
const motionOn = () => animate && !(win?.windowed ?? false);
|
|
362
|
+
function rowIn(node: Element) {
|
|
363
|
+
return motionSlide(node, { duration: motionOn() ? 'fast' : 0, opacity: true });
|
|
364
|
+
}
|
|
365
|
+
function rowOut(node: Element) {
|
|
366
|
+
(node as HTMLElement).inert = true;
|
|
367
|
+
node.setAttribute('aria-hidden', 'true');
|
|
368
|
+
return motionSlide(node, { duration: motionOn() ? 'fast' : 0, opacity: true });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ── Imperative API (bind:this) ──────────────────────────────────────────
|
|
372
|
+
export function expandAll() {
|
|
373
|
+
setExpanded(new Set(collectBranchIds(nodes, getId, (n) => getChildren(n))));
|
|
374
|
+
}
|
|
375
|
+
export function collapseAll() {
|
|
376
|
+
setExpanded(new Set());
|
|
377
|
+
}
|
|
378
|
+
/** Open every ancestor of `id`, then focus it. */
|
|
379
|
+
export async function reveal(id: string) {
|
|
380
|
+
const path = pathToNode(nodes, id, getId, (n) => getChildren(n));
|
|
381
|
+
if (!path) return;
|
|
382
|
+
expand(path.slice(0, -1));
|
|
383
|
+
await tick();
|
|
384
|
+
await focusRow(id);
|
|
385
|
+
}
|
|
386
|
+
export function focus(id?: string) {
|
|
387
|
+
const target = id ?? tabStopId;
|
|
388
|
+
if (target != null) void focusRow(target);
|
|
389
|
+
}
|
|
390
|
+
/** Visible ids in render order. */
|
|
391
|
+
export function visibleIds(): string[] {
|
|
392
|
+
return rows.map((r) => r.id);
|
|
393
|
+
}
|
|
394
|
+
</script>
|
|
395
|
+
|
|
396
|
+
{#snippet defaultIcon(row: { hasChildren: boolean; expanded: boolean })}
|
|
397
|
+
{#if row.hasChildren}
|
|
398
|
+
{#if row.expanded}
|
|
399
|
+
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
|
400
|
+
<path stroke-linejoin="round" d="M2.75 6.25V5A1.75 1.75 0 0 1 4.5 3.25h3l1.75 2h6.25A1.75 1.75 0 0 1 17.25 7v.5" />
|
|
401
|
+
<path stroke-linejoin="round" d="M2.2 8.75h15.1a.9.9 0 0 1 .87 1.12l-1.36 5.4a1.75 1.75 0 0 1-1.7 1.33H4.4a1.75 1.75 0 0 1-1.73-1.5L2.2 8.75Z" />
|
|
402
|
+
</svg>
|
|
403
|
+
{:else}
|
|
404
|
+
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
|
405
|
+
<path stroke-linejoin="round" d="M2.75 5A1.75 1.75 0 0 1 4.5 3.25h3l1.75 2h6.25A1.75 1.75 0 0 1 17.25 7v7.25a1.75 1.75 0 0 1-1.75 1.75h-11a1.75 1.75 0 0 1-1.75-1.75V5Z" />
|
|
406
|
+
</svg>
|
|
407
|
+
{/if}
|
|
408
|
+
{:else}
|
|
409
|
+
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
|
410
|
+
<path stroke-linejoin="round" d="M5.5 2.75h5.25l4.5 4.5v9a1 1 0 0 1-1 1h-8.75a1 1 0 0 1-1-1v-12.5a1 1 0 0 1 1-1Z" />
|
|
411
|
+
<path stroke-linejoin="round" d="M10.5 2.75v4.5h4.5" />
|
|
412
|
+
</svg>
|
|
413
|
+
{/if}
|
|
414
|
+
{/snippet}
|
|
415
|
+
|
|
416
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
417
|
+
<ul
|
|
418
|
+
role="tree"
|
|
419
|
+
class="jatui-tree jatui-tree-{size} {className}"
|
|
420
|
+
class:has-max={!!maxHeight}
|
|
421
|
+
aria-label={ariaLabel}
|
|
422
|
+
aria-multiselectable={selectionMode === 'multiple' ? 'true' : undefined}
|
|
423
|
+
style:max-height={maxHeight}
|
|
424
|
+
onkeydown={onKeydown}
|
|
425
|
+
use:anchorAction
|
|
426
|
+
>
|
|
427
|
+
{#if rows.length === 0}
|
|
428
|
+
<li role="none" class="tree-empty">
|
|
429
|
+
{#if empty}{@render empty()}{:else}{emptyText}{/if}
|
|
430
|
+
</li>
|
|
431
|
+
{:else}
|
|
432
|
+
{#if win && win.paddingTop > 0}
|
|
433
|
+
<li role="none" class="tree-spacer" style:height="{win.paddingTop}px"></li>
|
|
434
|
+
{/if}
|
|
435
|
+
{#each slots as { row, gap, bridge } (row.id)}
|
|
436
|
+
{@const isSel = selected.has(row.id)}
|
|
437
|
+
{@const isDis = disabled(row.id)}
|
|
438
|
+
{@const ctx = {
|
|
439
|
+
id: row.id,
|
|
440
|
+
level: row.level,
|
|
441
|
+
expanded: row.expanded,
|
|
442
|
+
hasChildren: row.hasChildren,
|
|
443
|
+
selected: isSel,
|
|
444
|
+
focused: focusedId === row.id,
|
|
445
|
+
disabled: isDis
|
|
446
|
+
}}
|
|
447
|
+
{#if gap > 0}
|
|
448
|
+
<li role="none" class="tree-spacer" style:height="{gap}px"></li>
|
|
449
|
+
{/if}
|
|
450
|
+
<!-- Keys are handled once on the tree (roving focus), not per row. -->
|
|
451
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
452
|
+
<li
|
|
453
|
+
id={domId(row.id)}
|
|
454
|
+
role="treeitem"
|
|
455
|
+
class="tree-row"
|
|
456
|
+
class:is-selected={isSel}
|
|
457
|
+
class:is-disabled={isDis}
|
|
458
|
+
class:is-leaf={!row.hasChildren}
|
|
459
|
+
aria-level={row.level}
|
|
460
|
+
aria-posinset={row.posinset}
|
|
461
|
+
aria-setsize={row.setsize}
|
|
462
|
+
aria-expanded={row.hasChildren ? row.expanded : undefined}
|
|
463
|
+
aria-selected={selectionMode === 'none' ? undefined : isSel}
|
|
464
|
+
aria-disabled={isDis || undefined}
|
|
465
|
+
aria-labelledby="{domId(row.id)}-l"
|
|
466
|
+
data-tree-id={row.id}
|
|
467
|
+
tabindex={row.id === tabStopId ? 0 : -1}
|
|
468
|
+
style:--level={row.level}
|
|
469
|
+
onclick={(e) => onRowClick(e, row)}
|
|
470
|
+
ondblclick={() => !isDis && onAction?.(row.node)}
|
|
471
|
+
oncontextmenu={onRowContextMenu ? (e) => onRowContextMenu(row.node, e) : undefined}
|
|
472
|
+
onfocus={() => (focusedId = row.id)}
|
|
473
|
+
use:measureAction={row.id}
|
|
474
|
+
in:rowIn
|
|
475
|
+
out:rowOut
|
|
476
|
+
>
|
|
477
|
+
{#if showGuides}
|
|
478
|
+
<span class="tree-guides" aria-hidden="true">
|
|
479
|
+
{#each row.guides as on, c}
|
|
480
|
+
{#if on}<span class="g g-trunk" style:--c={c}></span>{/if}
|
|
481
|
+
{/each}
|
|
482
|
+
{#if row.level > 1}
|
|
483
|
+
<span class="g g-elbow" style:--c={row.level - 2}></span>
|
|
484
|
+
{#if !row.isLastSibling}<span class="g g-tail" style:--c={row.level - 2}></span>{/if}
|
|
485
|
+
{/if}
|
|
486
|
+
{#if bridge}<span class="g g-bridge" style:--c={row.level - 1}></span>{/if}
|
|
487
|
+
</span>
|
|
488
|
+
{/if}
|
|
489
|
+
<!-- Pointer-only affordance; the keyboard toggles with ArrowRight/ArrowLeft. -->
|
|
490
|
+
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
|
491
|
+
<span
|
|
492
|
+
class="tree-chevron"
|
|
493
|
+
class:is-open={row.expanded}
|
|
494
|
+
aria-hidden="true"
|
|
495
|
+
onclick={row.hasChildren ? (e) => onChevronClick(e, row) : undefined}
|
|
496
|
+
>
|
|
497
|
+
{#if row.hasChildren}
|
|
498
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.75">
|
|
499
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="m6 4 4 4-4 4" />
|
|
500
|
+
</svg>
|
|
501
|
+
{/if}
|
|
502
|
+
</span>
|
|
503
|
+
{#if icon}
|
|
504
|
+
<span class="tree-icon" aria-hidden="true">{@render icon(row.node, ctx)}</span>
|
|
505
|
+
{:else if icons}
|
|
506
|
+
<span class="tree-icon" aria-hidden="true">{@render defaultIcon(row)}</span>
|
|
507
|
+
{/if}
|
|
508
|
+
<span class="tree-label" id="{domId(row.id)}-l">
|
|
509
|
+
{#if label}{@render label(row.node, ctx)}{:else}<bdi>{labelOf(row.node, row.id)}</bdi>{/if}
|
|
510
|
+
</span>
|
|
511
|
+
{#if trailing}
|
|
512
|
+
<span class="tree-trailing">{@render trailing(row.node, ctx)}</span>
|
|
513
|
+
{/if}
|
|
514
|
+
</li>
|
|
515
|
+
{/each}
|
|
516
|
+
{#if win && win.paddingBottom > 0}
|
|
517
|
+
<li role="none" class="tree-spacer" style:height="{win.paddingBottom}px"></li>
|
|
518
|
+
{/if}
|
|
519
|
+
{/if}
|
|
520
|
+
</ul>
|
|
521
|
+
|
|
522
|
+
<style>
|
|
523
|
+
.jatui-tree {
|
|
524
|
+
--tree-pad: 0.375rem;
|
|
525
|
+
--tree-indent: 1.25rem;
|
|
526
|
+
--tree-chevron: 1rem;
|
|
527
|
+
--tree-row-h: 2rem;
|
|
528
|
+
--tree-guide: color-mix(in oklch, var(--color-base-content) 22%, var(--color-base-100));
|
|
529
|
+
list-style: none;
|
|
530
|
+
margin: 0;
|
|
531
|
+
padding: var(--jatui-space-xs, 2px);
|
|
532
|
+
font-size: 0.875rem;
|
|
533
|
+
color: var(--color-base-content);
|
|
534
|
+
}
|
|
535
|
+
.jatui-tree-sm {
|
|
536
|
+
--tree-indent: 1rem;
|
|
537
|
+
--tree-row-h: 1.75rem;
|
|
538
|
+
font-size: 0.8125rem;
|
|
539
|
+
}
|
|
540
|
+
.jatui-tree.has-max {
|
|
541
|
+
overflow: auto;
|
|
542
|
+
overscroll-behavior: contain;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.tree-row {
|
|
546
|
+
position: relative;
|
|
547
|
+
display: flex;
|
|
548
|
+
align-items: center;
|
|
549
|
+
gap: var(--jatui-space-md, 6px);
|
|
550
|
+
min-height: var(--tree-row-h);
|
|
551
|
+
padding-inline-start: calc(var(--tree-pad) + (var(--level) - 1) * var(--tree-indent));
|
|
552
|
+
padding-inline-end: var(--jatui-space-base, 8px);
|
|
553
|
+
border-radius: var(--jatui-radius-field, 0.25rem);
|
|
554
|
+
cursor: default;
|
|
555
|
+
outline: none;
|
|
556
|
+
user-select: none;
|
|
557
|
+
}
|
|
558
|
+
.tree-row:hover {
|
|
559
|
+
background: color-mix(in oklch, var(--color-base-content) 6%, transparent);
|
|
560
|
+
}
|
|
561
|
+
.tree-row.is-selected {
|
|
562
|
+
background: color-mix(in oklch, var(--color-primary) 14%, transparent);
|
|
563
|
+
}
|
|
564
|
+
.tree-row:focus-visible {
|
|
565
|
+
outline: 2px solid var(--color-primary);
|
|
566
|
+
outline-offset: -2px;
|
|
567
|
+
}
|
|
568
|
+
.tree-row.is-disabled {
|
|
569
|
+
opacity: 0.5;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
.tree-chevron {
|
|
573
|
+
flex: none;
|
|
574
|
+
display: inline-flex;
|
|
575
|
+
align-items: center;
|
|
576
|
+
justify-content: center;
|
|
577
|
+
width: var(--tree-chevron);
|
|
578
|
+
height: var(--tree-chevron);
|
|
579
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
580
|
+
border-radius: var(--jatui-radius-selector, 0.375rem);
|
|
581
|
+
}
|
|
582
|
+
.tree-chevron svg {
|
|
583
|
+
width: 0.875rem;
|
|
584
|
+
height: 0.875rem;
|
|
585
|
+
transition: transform var(--motion-fast, 150ms) var(--motion-standard, ease);
|
|
586
|
+
}
|
|
587
|
+
.tree-chevron.is-open svg {
|
|
588
|
+
transform: rotate(90deg);
|
|
589
|
+
}
|
|
590
|
+
.tree-chevron:not(:empty):hover {
|
|
591
|
+
color: var(--color-base-content);
|
|
592
|
+
background: color-mix(in oklch, var(--color-base-content) 8%, transparent);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
.tree-icon {
|
|
596
|
+
flex: none;
|
|
597
|
+
display: inline-flex;
|
|
598
|
+
width: 1rem;
|
|
599
|
+
height: 1rem;
|
|
600
|
+
color: color-mix(in oklch, var(--color-base-content) 60%, transparent);
|
|
601
|
+
}
|
|
602
|
+
.tree-icon :global(svg) {
|
|
603
|
+
width: 100%;
|
|
604
|
+
height: 100%;
|
|
605
|
+
}
|
|
606
|
+
.tree-row.is-selected .tree-icon {
|
|
607
|
+
color: var(--color-primary);
|
|
608
|
+
}
|
|
609
|
+
.tree-label {
|
|
610
|
+
flex: 1 1 auto;
|
|
611
|
+
min-width: 0;
|
|
612
|
+
overflow: hidden;
|
|
613
|
+
text-overflow: ellipsis;
|
|
614
|
+
white-space: nowrap;
|
|
615
|
+
}
|
|
616
|
+
.tree-trailing {
|
|
617
|
+
flex: none;
|
|
618
|
+
display: inline-flex;
|
|
619
|
+
align-items: center;
|
|
620
|
+
gap: var(--jatui-space-sm, 4px);
|
|
621
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
622
|
+
font-size: 0.75rem;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/* Guides — purely presentational, never in the a11y tree. */
|
|
626
|
+
.tree-guides {
|
|
627
|
+
position: absolute;
|
|
628
|
+
inset: 0;
|
|
629
|
+
pointer-events: none;
|
|
630
|
+
}
|
|
631
|
+
.g {
|
|
632
|
+
position: absolute;
|
|
633
|
+
inset-inline-start: calc(var(--tree-pad) + var(--c) * var(--tree-indent) + var(--tree-chevron) / 2);
|
|
634
|
+
border-color: var(--tree-guide);
|
|
635
|
+
border-style: solid;
|
|
636
|
+
border-width: 0;
|
|
637
|
+
}
|
|
638
|
+
.g-trunk {
|
|
639
|
+
top: 0;
|
|
640
|
+
bottom: 0;
|
|
641
|
+
border-inline-start-width: 1px;
|
|
642
|
+
}
|
|
643
|
+
.g-elbow {
|
|
644
|
+
top: 0;
|
|
645
|
+
height: 50%;
|
|
646
|
+
width: calc(var(--tree-indent) - var(--tree-chevron) / 2 - 3px);
|
|
647
|
+
border-inline-start-width: 1px;
|
|
648
|
+
border-bottom-width: 1px;
|
|
649
|
+
border-end-start-radius: var(--jatui-radius-selector, 0.375rem);
|
|
650
|
+
}
|
|
651
|
+
.is-leaf .g-elbow {
|
|
652
|
+
width: calc(var(--tree-indent) + var(--tree-chevron) / 2 - 3px);
|
|
653
|
+
}
|
|
654
|
+
.g-tail {
|
|
655
|
+
top: 50%;
|
|
656
|
+
bottom: 0;
|
|
657
|
+
border-inline-start-width: 1px;
|
|
658
|
+
}
|
|
659
|
+
.g-bridge {
|
|
660
|
+
top: calc(50% + var(--tree-chevron) / 2);
|
|
661
|
+
bottom: 0;
|
|
662
|
+
border-inline-start-width: 1px;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
.tree-empty {
|
|
666
|
+
padding: var(--jatui-space-lg, 12px);
|
|
667
|
+
text-align: center;
|
|
668
|
+
color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
|
|
669
|
+
}
|
|
670
|
+
.tree-spacer {
|
|
671
|
+
padding: 0;
|
|
672
|
+
margin: 0;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
@media (prefers-reduced-motion: reduce) {
|
|
676
|
+
.tree-chevron svg {
|
|
677
|
+
transition: none;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
</style>
|