@marianmeres/stuic 3.45.1 → 3.46.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/dist/components/DropdownMenu/DropdownMenu.svelte +8 -0
- package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +3 -0
- package/dist/components/DropdownMenu/index.css +0 -1
- package/dist/components/Tree/Tree.svelte +661 -0
- package/dist/components/Tree/Tree.svelte.d.ts +95 -0
- package/dist/components/Tree/index.css +186 -0
- package/dist/components/Tree/index.d.ts +2 -0
- package/dist/components/Tree/index.js +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +100 -97
|
@@ -190,6 +190,9 @@
|
|
|
190
190
|
onClose?: () => void;
|
|
191
191
|
/** Called when any action item is selected (fallback if item has no onSelect) */
|
|
192
192
|
onSelect?: (item: DropdownMenuActionItem) => void | boolean | Promise<void | boolean>;
|
|
193
|
+
/** Reserve scrollbar space to prevent layout shift on open (useful for long lists).
|
|
194
|
+
* When undefined, auto-enables if items count >= 7. */
|
|
195
|
+
scrollbarGutter?: boolean;
|
|
193
196
|
/** Reference to trigger element */
|
|
194
197
|
triggerEl?: HTMLButtonElement;
|
|
195
198
|
/** Reference to dropdown element */
|
|
@@ -291,6 +294,7 @@
|
|
|
291
294
|
onSelect,
|
|
292
295
|
triggerEl = $bindable(),
|
|
293
296
|
dropdownEl = $bindable(),
|
|
297
|
+
scrollbarGutter,
|
|
294
298
|
noScrollLock,
|
|
295
299
|
...rest
|
|
296
300
|
}: Props = $props();
|
|
@@ -683,6 +687,8 @@
|
|
|
683
687
|
|
|
684
688
|
// Position styles for CSS Anchor Positioning
|
|
685
689
|
let dropdownStyle = $derived.by(() => {
|
|
690
|
+
const useGutter = scrollbarGutter ?? items.length >= 7;
|
|
691
|
+
const gutterStyle = useGutter ? "scrollbar-gutter: stable;" : "";
|
|
686
692
|
if (isSupported) {
|
|
687
693
|
// Use fixed height when search is enabled AND position is a "top" variant
|
|
688
694
|
// to prevent jarring resize during filtering (dropdown grows upward)
|
|
@@ -697,6 +703,7 @@
|
|
|
697
703
|
position-area: ${POSITION_MAP[position] || "bottom"};
|
|
698
704
|
margin: ${offset};
|
|
699
705
|
${heightStyle}
|
|
706
|
+
${gutterStyle}
|
|
700
707
|
`;
|
|
701
708
|
} else {
|
|
702
709
|
// Fallback: centered modal overlay
|
|
@@ -711,6 +718,7 @@
|
|
|
711
718
|
transform: translate(-50%, -50%);
|
|
712
719
|
max-width: 90vw;
|
|
713
720
|
${heightStyle}
|
|
721
|
+
${gutterStyle}
|
|
714
722
|
z-index: 50;
|
|
715
723
|
`;
|
|
716
724
|
}
|
|
@@ -159,6 +159,9 @@ export interface Props extends Omit<HTMLButtonAttributes, "children"> {
|
|
|
159
159
|
onClose?: () => void;
|
|
160
160
|
/** Called when any action item is selected (fallback if item has no onSelect) */
|
|
161
161
|
onSelect?: (item: DropdownMenuActionItem) => void | boolean | Promise<void | boolean>;
|
|
162
|
+
/** Reserve scrollbar space to prevent layout shift on open (useful for long lists).
|
|
163
|
+
* When undefined, auto-enables if items count >= 7. */
|
|
164
|
+
scrollbarGutter?: boolean;
|
|
162
165
|
/** Reference to trigger element */
|
|
163
166
|
triggerEl?: HTMLButtonElement;
|
|
164
167
|
/** Reference to dropdown element */
|
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
3
|
+
import type { TreeNodeDTO } from "@marianmeres/tree";
|
|
4
|
+
import type { Snippet } from "svelte";
|
|
5
|
+
|
|
6
|
+
export type TreeDropPosition = "before" | "after" | "inside";
|
|
7
|
+
|
|
8
|
+
export interface TreeMoveEvent<T = unknown> {
|
|
9
|
+
/** The node being dragged */
|
|
10
|
+
source: TreeNodeDTO<T>;
|
|
11
|
+
/** The node being dropped onto/near */
|
|
12
|
+
target: TreeNodeDTO<T>;
|
|
13
|
+
/** Where relative to target: 'before' (sibling above), 'after' (sibling below), 'inside' (child) */
|
|
14
|
+
position: TreeDropPosition;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface Props<T = unknown> extends Omit<
|
|
18
|
+
HTMLAttributes<HTMLDivElement>,
|
|
19
|
+
"children"
|
|
20
|
+
> {
|
|
21
|
+
/** The tree data (use tree.toJSON().children or raw TreeNodeDTO[]) */
|
|
22
|
+
items: TreeNodeDTO<T>[];
|
|
23
|
+
|
|
24
|
+
/** Render snippet for each item's content - receives (item, depth, isExpanded) */
|
|
25
|
+
renderItem?: Snippet<[TreeNodeDTO<T>, number, boolean]>;
|
|
26
|
+
|
|
27
|
+
/** Render snippet for item icon - receives (item, depth, isExpanded) */
|
|
28
|
+
renderIcon?: Snippet<[TreeNodeDTO<T>, number, boolean]>;
|
|
29
|
+
|
|
30
|
+
/** Active/selected item ID */
|
|
31
|
+
activeId?: string;
|
|
32
|
+
|
|
33
|
+
/** Callback to check if item is active (alternative to activeId) */
|
|
34
|
+
isActive?: (item: TreeNodeDTO<T>) => boolean;
|
|
35
|
+
|
|
36
|
+
/** Callback when an item is selected */
|
|
37
|
+
onSelect?: (item: TreeNodeDTO<T>) => void;
|
|
38
|
+
|
|
39
|
+
/** Callback when a branch is toggled */
|
|
40
|
+
onToggle?: (item: TreeNodeDTO<T>, expanded: boolean) => void;
|
|
41
|
+
|
|
42
|
+
/** Sort comparator (applied at each level) */
|
|
43
|
+
sort?: (a: TreeNodeDTO<T>, b: TreeNodeDTO<T>) => number;
|
|
44
|
+
|
|
45
|
+
/** Default expanded state for branches (default: false) */
|
|
46
|
+
defaultExpanded?: boolean;
|
|
47
|
+
|
|
48
|
+
/** Set of initially expanded branch IDs */
|
|
49
|
+
expandedIds?: Set<string>;
|
|
50
|
+
|
|
51
|
+
/** Enable localStorage persistence for expand/collapse state */
|
|
52
|
+
persistState?: boolean;
|
|
53
|
+
|
|
54
|
+
/** Storage key prefix for localStorage (default: 'stuic-tree') */
|
|
55
|
+
storageKeyPrefix?: string;
|
|
56
|
+
|
|
57
|
+
/** Enable drag-and-drop node reordering (default: false) */
|
|
58
|
+
draggable?: boolean;
|
|
59
|
+
|
|
60
|
+
/** Per-item drag control: return false to prevent dragging a specific item */
|
|
61
|
+
isDraggable?: (item: TreeNodeDTO<T>) => boolean;
|
|
62
|
+
|
|
63
|
+
/** Per-item drop target control: return false to prevent dropping onto a specific item */
|
|
64
|
+
isDropTarget?: (item: TreeNodeDTO<T>) => boolean;
|
|
65
|
+
|
|
66
|
+
/** Called on drop. Return false or throw to reject the move. */
|
|
67
|
+
onMove?: (event: TreeMoveEvent<T>) => void | false | Promise<void | false>;
|
|
68
|
+
|
|
69
|
+
/** Called when onMove throws an error */
|
|
70
|
+
onError?: (error: unknown) => void;
|
|
71
|
+
|
|
72
|
+
/** Delay in ms before auto-expanding a collapsed branch on drag-over (default: 800) */
|
|
73
|
+
dragExpandDelay?: number;
|
|
74
|
+
|
|
75
|
+
/** Skip all default styling */
|
|
76
|
+
unstyled?: boolean;
|
|
77
|
+
|
|
78
|
+
/** Classes for the wrapper element */
|
|
79
|
+
class?: string;
|
|
80
|
+
|
|
81
|
+
/** Element reference */
|
|
82
|
+
el?: HTMLElement;
|
|
83
|
+
|
|
84
|
+
/** Classes for individual items */
|
|
85
|
+
classItem?: string;
|
|
86
|
+
/** Classes for active items */
|
|
87
|
+
classItemActive?: string;
|
|
88
|
+
/** Classes for icons */
|
|
89
|
+
classIcon?: string;
|
|
90
|
+
/** Classes for labels */
|
|
91
|
+
classLabel?: string;
|
|
92
|
+
/** Classes for children container */
|
|
93
|
+
classChildren?: string;
|
|
94
|
+
/** Classes for chevron icon */
|
|
95
|
+
classChevron?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const TREE_BASE_CLASSES = "stuic-tree";
|
|
99
|
+
export const TREE_ITEM_CLASSES = "stuic-tree-item";
|
|
100
|
+
export const TREE_CHILDREN_CLASSES = "stuic-tree-children";
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<script lang="ts" generics="T = unknown">
|
|
104
|
+
import type { TreeNodeDTO as TreeNode } from "@marianmeres/tree";
|
|
105
|
+
import { twMerge } from "../../utils/tw-merge.js";
|
|
106
|
+
import { localStorageValue } from "../../utils/storage-abstraction.js";
|
|
107
|
+
import { prefersReducedMotion } from "../../utils/prefers-reduced-motion.svelte.js";
|
|
108
|
+
import { iconChevronRight } from "../../icons/index.js";
|
|
109
|
+
import { slide } from "svelte/transition";
|
|
110
|
+
import { SvelteSet } from "svelte/reactivity";
|
|
111
|
+
|
|
112
|
+
let {
|
|
113
|
+
items,
|
|
114
|
+
renderItem: renderItemSnippet,
|
|
115
|
+
renderIcon: renderIconSnippet,
|
|
116
|
+
activeId,
|
|
117
|
+
isActive,
|
|
118
|
+
onSelect,
|
|
119
|
+
onToggle,
|
|
120
|
+
sort,
|
|
121
|
+
defaultExpanded = false,
|
|
122
|
+
expandedIds,
|
|
123
|
+
persistState = false,
|
|
124
|
+
storageKeyPrefix = "stuic-tree",
|
|
125
|
+
draggable = false,
|
|
126
|
+
isDraggable,
|
|
127
|
+
isDropTarget,
|
|
128
|
+
onMove,
|
|
129
|
+
onError,
|
|
130
|
+
dragExpandDelay = 800,
|
|
131
|
+
unstyled = false,
|
|
132
|
+
class: classProp,
|
|
133
|
+
el = $bindable(),
|
|
134
|
+
classItem,
|
|
135
|
+
classItemActive,
|
|
136
|
+
classIcon,
|
|
137
|
+
classLabel,
|
|
138
|
+
classChildren,
|
|
139
|
+
classChevron,
|
|
140
|
+
...rest
|
|
141
|
+
}: Props<T> = $props();
|
|
142
|
+
|
|
143
|
+
const reducedMotion = prefersReducedMotion();
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// Helpers: localStorage persistence
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
function getStorageKey(itemId: string): string {
|
|
150
|
+
return `${storageKeyPrefix}-${itemId}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function loadState(itemId: string): boolean | undefined {
|
|
154
|
+
if (!persistState) return undefined;
|
|
155
|
+
return localStorageValue<boolean | undefined>(getStorageKey(itemId), undefined).get();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function saveState(itemId: string, expanded: boolean): void {
|
|
159
|
+
if (!persistState) return;
|
|
160
|
+
localStorageValue(getStorageKey(itemId), expanded).set(expanded);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Helpers: active state checks
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function checkItemActive(item: TreeNode<T>): boolean {
|
|
168
|
+
if (isActive) return isActive(item);
|
|
169
|
+
if (activeId) return item.id === activeId;
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function hasActiveDescendant(items: TreeNode<T>[]): boolean {
|
|
174
|
+
for (const item of items) {
|
|
175
|
+
if (checkItemActive(item)) return true;
|
|
176
|
+
if (item.children.length && hasActiveDescendant(item.children)) return true;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Expand/collapse state
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
function isBranch(item: TreeNode<T>): boolean {
|
|
186
|
+
return item.children.length > 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function collectBranches(items: TreeNode<T>[]): TreeNode<T>[] {
|
|
190
|
+
const result: TreeNode<T>[] = [];
|
|
191
|
+
for (const item of items) {
|
|
192
|
+
if (isBranch(item)) {
|
|
193
|
+
result.push(item);
|
|
194
|
+
result.push(...collectBranches(item.children));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function computeExpandedSet(): SvelteSet<string> {
|
|
201
|
+
const expanded = new SvelteSet<string>();
|
|
202
|
+
|
|
203
|
+
// Start from expandedIds prop if provided
|
|
204
|
+
if (expandedIds) {
|
|
205
|
+
for (const id of expandedIds) expanded.add(id);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const branches = collectBranches(items);
|
|
209
|
+
for (const branch of branches) {
|
|
210
|
+
// First priority: localStorage
|
|
211
|
+
if (persistState) {
|
|
212
|
+
const stored = loadState(branch.id);
|
|
213
|
+
if (stored != null) {
|
|
214
|
+
if (stored) expanded.add(branch.id);
|
|
215
|
+
else expanded.delete(branch.id);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Second priority: already in expandedIds (handled above)
|
|
221
|
+
if (expandedIds?.has(branch.id)) continue;
|
|
222
|
+
|
|
223
|
+
// Third priority: auto-expand if has active descendant
|
|
224
|
+
if (hasActiveDescendant(branch.children)) {
|
|
225
|
+
expanded.add(branch.id);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Fourth: defaultExpanded
|
|
230
|
+
if (defaultExpanded) {
|
|
231
|
+
expanded.add(branch.id);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return expanded;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let expandedStates = $state(computeExpandedSet());
|
|
239
|
+
|
|
240
|
+
function isExpanded(itemId: string): boolean {
|
|
241
|
+
return expandedStates.has(itemId);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function getDescendantIds(item: TreeNode<T>): string[] {
|
|
245
|
+
const ids: string[] = [];
|
|
246
|
+
for (const child of item.children) {
|
|
247
|
+
ids.push(child.id);
|
|
248
|
+
ids.push(...getDescendantIds(child));
|
|
249
|
+
}
|
|
250
|
+
return ids;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function toggleExpanded(item: TreeNode<T>) {
|
|
254
|
+
const wasExpanded = expandedStates.has(item.id);
|
|
255
|
+
if (wasExpanded) {
|
|
256
|
+
// Collapse: also collapse all descendants
|
|
257
|
+
expandedStates.delete(item.id);
|
|
258
|
+
saveState(item.id, false);
|
|
259
|
+
for (const id of getDescendantIds(item)) {
|
|
260
|
+
expandedStates.delete(id);
|
|
261
|
+
saveState(id, false);
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
expandedStates.add(item.id);
|
|
265
|
+
saveState(item.id, true);
|
|
266
|
+
}
|
|
267
|
+
onToggle?.(item, !wasExpanded);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
// Sorting
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
function sortedItems(nodeItems: TreeNode<T>[]): TreeNode<T>[] {
|
|
275
|
+
if (!sort) return nodeItems;
|
|
276
|
+
return [...nodeItems].sort(sort);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
// Keyboard navigation (roving tabindex)
|
|
281
|
+
// ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
let focusedId = $state<string | null>(null);
|
|
284
|
+
|
|
285
|
+
function flattenVisible(nodeItems: TreeNode<T>[]): TreeNode<T>[] {
|
|
286
|
+
const result: TreeNode<T>[] = [];
|
|
287
|
+
for (const item of sortedItems(nodeItems)) {
|
|
288
|
+
result.push(item);
|
|
289
|
+
if (isBranch(item) && isExpanded(item.id)) {
|
|
290
|
+
result.push(...flattenVisible(item.children));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function findParent(
|
|
297
|
+
nodeItems: TreeNode<T>[],
|
|
298
|
+
targetId: string,
|
|
299
|
+
parent: TreeNode<T> | null = null
|
|
300
|
+
): TreeNode<T> | null {
|
|
301
|
+
for (const item of nodeItems) {
|
|
302
|
+
if (item.id === targetId) return parent;
|
|
303
|
+
if (item.children.length) {
|
|
304
|
+
const found = findParent(item.children, targetId, item);
|
|
305
|
+
if (found) return found;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function focusItem(id: string) {
|
|
312
|
+
focusedId = id;
|
|
313
|
+
// Focus the DOM element
|
|
314
|
+
const itemEl = el?.querySelector(`[data-tree-id="${id}"]`) as HTMLElement | null;
|
|
315
|
+
itemEl?.focus();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
319
|
+
const visible = flattenVisible(items);
|
|
320
|
+
if (!visible.length) return;
|
|
321
|
+
|
|
322
|
+
const currentIndex = focusedId ? visible.findIndex((n) => n.id === focusedId) : -1;
|
|
323
|
+
const current = currentIndex >= 0 ? visible[currentIndex] : null;
|
|
324
|
+
|
|
325
|
+
switch (e.key) {
|
|
326
|
+
case "ArrowDown": {
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
const nextIndex = Math.min(currentIndex + 1, visible.length - 1);
|
|
329
|
+
focusItem(visible[nextIndex].id);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
case "ArrowUp": {
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
const prevIndex = currentIndex <= 0 ? 0 : currentIndex - 1;
|
|
335
|
+
focusItem(visible[prevIndex].id);
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
case "ArrowRight": {
|
|
339
|
+
e.preventDefault();
|
|
340
|
+
if (current && isBranch(current)) {
|
|
341
|
+
if (!isExpanded(current.id)) {
|
|
342
|
+
toggleExpanded(current);
|
|
343
|
+
} else if (current.children.length) {
|
|
344
|
+
// Move to first child
|
|
345
|
+
const sorted = sortedItems(current.children);
|
|
346
|
+
focusItem(sorted[0].id);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
break;
|
|
350
|
+
}
|
|
351
|
+
case "ArrowLeft": {
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
if (current && isBranch(current) && isExpanded(current.id)) {
|
|
354
|
+
toggleExpanded(current);
|
|
355
|
+
} else if (current) {
|
|
356
|
+
// Move to parent
|
|
357
|
+
const parent = findParent(items, current.id);
|
|
358
|
+
if (parent) focusItem(parent.id);
|
|
359
|
+
}
|
|
360
|
+
break;
|
|
361
|
+
}
|
|
362
|
+
case "Enter":
|
|
363
|
+
case " ": {
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
if (current) {
|
|
366
|
+
if (isBranch(current)) {
|
|
367
|
+
toggleExpanded(current);
|
|
368
|
+
}
|
|
369
|
+
onSelect?.(current);
|
|
370
|
+
}
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
case "Home": {
|
|
374
|
+
e.preventDefault();
|
|
375
|
+
if (visible.length) focusItem(visible[0].id);
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
case "End": {
|
|
379
|
+
e.preventDefault();
|
|
380
|
+
if (visible.length) focusItem(visible[visible.length - 1].id);
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Item click handling
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
function handleItemClick(item: TreeNode<T>) {
|
|
391
|
+
focusedId = item.id;
|
|
392
|
+
if (isBranch(item)) {
|
|
393
|
+
toggleExpanded(item);
|
|
394
|
+
}
|
|
395
|
+
onSelect?.(item);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Transition
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
let transitionDuration = $derived(reducedMotion.current ? 0 : 150);
|
|
403
|
+
|
|
404
|
+
// ---------------------------------------------------------------------------
|
|
405
|
+
// Drag and drop
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
|
|
408
|
+
let dragSourceId = $state<string | null>(null);
|
|
409
|
+
let dropTargetId = $state<string | null>(null);
|
|
410
|
+
let dropPos = $state<TreeDropPosition | null>(null);
|
|
411
|
+
let dragExpandTimer: ReturnType<typeof setTimeout> | null = null;
|
|
412
|
+
|
|
413
|
+
function findNodeById(nodeItems: TreeNode<T>[], id: string): TreeNode<T> | null {
|
|
414
|
+
for (const item of nodeItems) {
|
|
415
|
+
if (item.id === id) return item;
|
|
416
|
+
if (item.children.length) {
|
|
417
|
+
const found = findNodeById(item.children, id);
|
|
418
|
+
if (found) return found;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function isDescendantOf(ancestorId: string, nodeId: string): boolean {
|
|
425
|
+
const ancestor = findNodeById(items, ancestorId);
|
|
426
|
+
if (!ancestor) return false;
|
|
427
|
+
return getDescendantIds(ancestor).includes(nodeId);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function calcDropPosition(
|
|
431
|
+
e: DragEvent,
|
|
432
|
+
targetEl: HTMLElement,
|
|
433
|
+
targetIsBranch: boolean
|
|
434
|
+
): TreeDropPosition {
|
|
435
|
+
const rect = targetEl.getBoundingClientRect();
|
|
436
|
+
const y = e.clientY - rect.top;
|
|
437
|
+
const third = rect.height / 3;
|
|
438
|
+
|
|
439
|
+
if (y < third) return "before";
|
|
440
|
+
if (y > third * 2) return "after";
|
|
441
|
+
return targetIsBranch ? "inside" : y < rect.height / 2 ? "before" : "after";
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function clearDragExpandTimer() {
|
|
445
|
+
if (dragExpandTimer) {
|
|
446
|
+
clearTimeout(dragExpandTimer);
|
|
447
|
+
dragExpandTimer = null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function resetDragState() {
|
|
452
|
+
dragSourceId = null;
|
|
453
|
+
dropTargetId = null;
|
|
454
|
+
dropPos = null;
|
|
455
|
+
clearDragExpandTimer();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function handleDragStart(e: DragEvent, item: TreeNode<T>) {
|
|
459
|
+
if (!draggable || isDraggable?.(item) === false) {
|
|
460
|
+
e.preventDefault();
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
e.dataTransfer!.effectAllowed = "move";
|
|
464
|
+
e.dataTransfer!.setData("text/plain", item.id);
|
|
465
|
+
dragSourceId = item.id;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function handleDragOver(e: DragEvent, item: TreeNode<T>) {
|
|
469
|
+
if (!draggable || !dragSourceId) return;
|
|
470
|
+
|
|
471
|
+
// Can't drop on self
|
|
472
|
+
if (item.id === dragSourceId) return;
|
|
473
|
+
|
|
474
|
+
// Can't drop ancestor into its own subtree
|
|
475
|
+
if (isDescendantOf(dragSourceId, item.id)) return;
|
|
476
|
+
|
|
477
|
+
// Consumer-level drop target check
|
|
478
|
+
if (isDropTarget?.(item) === false) return;
|
|
479
|
+
|
|
480
|
+
e.preventDefault();
|
|
481
|
+
e.stopPropagation();
|
|
482
|
+
e.dataTransfer!.dropEffect = "move";
|
|
483
|
+
|
|
484
|
+
const targetEl = (e.currentTarget as HTMLElement).querySelector(
|
|
485
|
+
":scope > button"
|
|
486
|
+
) as HTMLElement;
|
|
487
|
+
if (!targetEl) return;
|
|
488
|
+
|
|
489
|
+
const pos = calcDropPosition(e, targetEl, isBranch(item));
|
|
490
|
+
dropTargetId = item.id;
|
|
491
|
+
dropPos = pos;
|
|
492
|
+
|
|
493
|
+
// Auto-expand collapsed branches
|
|
494
|
+
if (pos === "inside" && isBranch(item) && !isExpanded(item.id)) {
|
|
495
|
+
if (!dragExpandTimer) {
|
|
496
|
+
dragExpandTimer = setTimeout(() => {
|
|
497
|
+
toggleExpanded(item);
|
|
498
|
+
dragExpandTimer = null;
|
|
499
|
+
}, dragExpandDelay);
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
clearDragExpandTimer();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function handleDragLeave(e: DragEvent) {
|
|
507
|
+
if (!draggable) return;
|
|
508
|
+
e.stopPropagation();
|
|
509
|
+
const currentTarget = e.currentTarget as HTMLElement;
|
|
510
|
+
const relatedTarget = e.relatedTarget as Node | null;
|
|
511
|
+
// Only clear if actually leaving the treeitem (not entering a child)
|
|
512
|
+
if (relatedTarget && currentTarget.contains(relatedTarget)) return;
|
|
513
|
+
if (dropTargetId) {
|
|
514
|
+
dropTargetId = null;
|
|
515
|
+
dropPos = null;
|
|
516
|
+
}
|
|
517
|
+
clearDragExpandTimer();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async function handleDrop(e: DragEvent, _item: TreeNode<T>) {
|
|
521
|
+
e.preventDefault();
|
|
522
|
+
e.stopPropagation();
|
|
523
|
+
if (!draggable || !dragSourceId || !dropPos || !dropTargetId) {
|
|
524
|
+
resetDragState();
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// Use tracked drop target from state, not the event target (may differ due to bubbling)
|
|
529
|
+
const source = findNodeById(items, dragSourceId);
|
|
530
|
+
const target = findNodeById(items, dropTargetId);
|
|
531
|
+
if (!source || !target || source.id === target.id) {
|
|
532
|
+
resetDragState();
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const event: TreeMoveEvent<T> = {
|
|
537
|
+
source,
|
|
538
|
+
target,
|
|
539
|
+
position: dropPos,
|
|
540
|
+
};
|
|
541
|
+
|
|
542
|
+
resetDragState();
|
|
543
|
+
|
|
544
|
+
if (onMove) {
|
|
545
|
+
try {
|
|
546
|
+
const result = await onMove(event);
|
|
547
|
+
if (result === false) return;
|
|
548
|
+
} catch (err) {
|
|
549
|
+
onError?.(err);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function handleDragEnd() {
|
|
555
|
+
resetDragState();
|
|
556
|
+
}
|
|
557
|
+
</script>
|
|
558
|
+
|
|
559
|
+
<div
|
|
560
|
+
bind:this={el}
|
|
561
|
+
class={twMerge(!unstyled && TREE_BASE_CLASSES, classProp)}
|
|
562
|
+
role="tree"
|
|
563
|
+
onkeydown={handleKeydown}
|
|
564
|
+
{...rest}
|
|
565
|
+
>
|
|
566
|
+
{#snippet renderNode(item: TreeNode<T>, depth: number)}
|
|
567
|
+
{@const branch = isBranch(item)}
|
|
568
|
+
{@const expanded = branch && isExpanded(item.id)}
|
|
569
|
+
{@const active = checkItemActive(item)}
|
|
570
|
+
{@const focused = focusedId === item.id}
|
|
571
|
+
|
|
572
|
+
<!-- svelte-ignore a11y_interactive_supports_focus -->
|
|
573
|
+
<div
|
|
574
|
+
role="treeitem"
|
|
575
|
+
aria-expanded={branch ? expanded : undefined}
|
|
576
|
+
aria-selected={active}
|
|
577
|
+
aria-level={depth + 1}
|
|
578
|
+
ondragover={draggable ? (e) => handleDragOver(e, item) : undefined}
|
|
579
|
+
ondragleave={draggable ? handleDragLeave : undefined}
|
|
580
|
+
ondrop={draggable ? (e) => handleDrop(e, item) : undefined}
|
|
581
|
+
data-drop-position={dropTargetId === item.id && dropPos ? dropPos : undefined}
|
|
582
|
+
>
|
|
583
|
+
<!-- The clickable row -->
|
|
584
|
+
<button
|
|
585
|
+
type="button"
|
|
586
|
+
class={twMerge(
|
|
587
|
+
!unstyled && TREE_ITEM_CLASSES,
|
|
588
|
+
active && classItemActive,
|
|
589
|
+
classItem
|
|
590
|
+
)}
|
|
591
|
+
data-tree-id={item.id}
|
|
592
|
+
data-active={!unstyled && active ? "" : undefined}
|
|
593
|
+
data-focused={!unstyled && focused ? "" : undefined}
|
|
594
|
+
data-branch={!unstyled && branch ? "" : undefined}
|
|
595
|
+
data-depth={depth}
|
|
596
|
+
data-dragging={dragSourceId === item.id ? "" : undefined}
|
|
597
|
+
draggable={draggable && isDraggable?.(item) !== false ? true : undefined}
|
|
598
|
+
style={!unstyled
|
|
599
|
+
? `padding-left: calc(${depth} * var(--stuic-tree-indent) + var(--stuic-tree-item-padding-x))`
|
|
600
|
+
: undefined}
|
|
601
|
+
tabindex={focused ? 0 : -1}
|
|
602
|
+
onclick={() => handleItemClick(item)}
|
|
603
|
+
onfocus={() => (focusedId = item.id)}
|
|
604
|
+
ondragstart={draggable ? (e) => handleDragStart(e, item) : undefined}
|
|
605
|
+
ondragend={draggable ? handleDragEnd : undefined}
|
|
606
|
+
>
|
|
607
|
+
<!-- Chevron for branches -->
|
|
608
|
+
{#if branch}
|
|
609
|
+
<span
|
|
610
|
+
class={twMerge(
|
|
611
|
+
"inline-block shrink-0 transition-transform duration-150",
|
|
612
|
+
expanded && "rotate-90",
|
|
613
|
+
classChevron
|
|
614
|
+
)}
|
|
615
|
+
>
|
|
616
|
+
{@html iconChevronRight({ size: 14 })}
|
|
617
|
+
</span>
|
|
618
|
+
{:else}
|
|
619
|
+
<!-- Spacer to align leaf items with branches -->
|
|
620
|
+
<span
|
|
621
|
+
class={twMerge("inline-block shrink-0", classChevron)}
|
|
622
|
+
style="width: 14px;"
|
|
623
|
+
></span>
|
|
624
|
+
{/if}
|
|
625
|
+
|
|
626
|
+
<!-- Custom or default icon -->
|
|
627
|
+
{#if renderIconSnippet}
|
|
628
|
+
<span class={twMerge("shrink-0", classIcon)}>
|
|
629
|
+
{@render renderIconSnippet(item, depth, expanded)}
|
|
630
|
+
</span>
|
|
631
|
+
{/if}
|
|
632
|
+
|
|
633
|
+
<!-- Custom or default label -->
|
|
634
|
+
<span class={twMerge("truncate", classLabel)}>
|
|
635
|
+
{#if renderItemSnippet}
|
|
636
|
+
{@render renderItemSnippet(item, depth, expanded)}
|
|
637
|
+
{:else}
|
|
638
|
+
{String(item.value)}
|
|
639
|
+
{/if}
|
|
640
|
+
</span>
|
|
641
|
+
</button>
|
|
642
|
+
|
|
643
|
+
<!-- Children -->
|
|
644
|
+
{#if branch && expanded}
|
|
645
|
+
<div
|
|
646
|
+
class={twMerge(!unstyled && TREE_CHILDREN_CLASSES, classChildren)}
|
|
647
|
+
role="group"
|
|
648
|
+
transition:slide={{ duration: transitionDuration }}
|
|
649
|
+
>
|
|
650
|
+
{#each sortedItems(item.children) as child (child.id)}
|
|
651
|
+
{@render renderNode(child, depth + 1)}
|
|
652
|
+
{/each}
|
|
653
|
+
</div>
|
|
654
|
+
{/if}
|
|
655
|
+
</div>
|
|
656
|
+
{/snippet}
|
|
657
|
+
|
|
658
|
+
{#each sortedItems(items) as item (item.id)}
|
|
659
|
+
{@render renderNode(item, 0)}
|
|
660
|
+
{/each}
|
|
661
|
+
</div>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
2
|
+
import type { TreeNodeDTO } from "@marianmeres/tree";
|
|
3
|
+
import type { Snippet } from "svelte";
|
|
4
|
+
export type TreeDropPosition = "before" | "after" | "inside";
|
|
5
|
+
export interface TreeMoveEvent<T = unknown> {
|
|
6
|
+
/** The node being dragged */
|
|
7
|
+
source: TreeNodeDTO<T>;
|
|
8
|
+
/** The node being dropped onto/near */
|
|
9
|
+
target: TreeNodeDTO<T>;
|
|
10
|
+
/** Where relative to target: 'before' (sibling above), 'after' (sibling below), 'inside' (child) */
|
|
11
|
+
position: TreeDropPosition;
|
|
12
|
+
}
|
|
13
|
+
export interface Props<T = unknown> extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
14
|
+
/** The tree data (use tree.toJSON().children or raw TreeNodeDTO[]) */
|
|
15
|
+
items: TreeNodeDTO<T>[];
|
|
16
|
+
/** Render snippet for each item's content - receives (item, depth, isExpanded) */
|
|
17
|
+
renderItem?: Snippet<[TreeNodeDTO<T>, number, boolean]>;
|
|
18
|
+
/** Render snippet for item icon - receives (item, depth, isExpanded) */
|
|
19
|
+
renderIcon?: Snippet<[TreeNodeDTO<T>, number, boolean]>;
|
|
20
|
+
/** Active/selected item ID */
|
|
21
|
+
activeId?: string;
|
|
22
|
+
/** Callback to check if item is active (alternative to activeId) */
|
|
23
|
+
isActive?: (item: TreeNodeDTO<T>) => boolean;
|
|
24
|
+
/** Callback when an item is selected */
|
|
25
|
+
onSelect?: (item: TreeNodeDTO<T>) => void;
|
|
26
|
+
/** Callback when a branch is toggled */
|
|
27
|
+
onToggle?: (item: TreeNodeDTO<T>, expanded: boolean) => void;
|
|
28
|
+
/** Sort comparator (applied at each level) */
|
|
29
|
+
sort?: (a: TreeNodeDTO<T>, b: TreeNodeDTO<T>) => number;
|
|
30
|
+
/** Default expanded state for branches (default: false) */
|
|
31
|
+
defaultExpanded?: boolean;
|
|
32
|
+
/** Set of initially expanded branch IDs */
|
|
33
|
+
expandedIds?: Set<string>;
|
|
34
|
+
/** Enable localStorage persistence for expand/collapse state */
|
|
35
|
+
persistState?: boolean;
|
|
36
|
+
/** Storage key prefix for localStorage (default: 'stuic-tree') */
|
|
37
|
+
storageKeyPrefix?: string;
|
|
38
|
+
/** Enable drag-and-drop node reordering (default: false) */
|
|
39
|
+
draggable?: boolean;
|
|
40
|
+
/** Per-item drag control: return false to prevent dragging a specific item */
|
|
41
|
+
isDraggable?: (item: TreeNodeDTO<T>) => boolean;
|
|
42
|
+
/** Per-item drop target control: return false to prevent dropping onto a specific item */
|
|
43
|
+
isDropTarget?: (item: TreeNodeDTO<T>) => boolean;
|
|
44
|
+
/** Called on drop. Return false or throw to reject the move. */
|
|
45
|
+
onMove?: (event: TreeMoveEvent<T>) => void | false | Promise<void | false>;
|
|
46
|
+
/** Called when onMove throws an error */
|
|
47
|
+
onError?: (error: unknown) => void;
|
|
48
|
+
/** Delay in ms before auto-expanding a collapsed branch on drag-over (default: 800) */
|
|
49
|
+
dragExpandDelay?: number;
|
|
50
|
+
/** Skip all default styling */
|
|
51
|
+
unstyled?: boolean;
|
|
52
|
+
/** Classes for the wrapper element */
|
|
53
|
+
class?: string;
|
|
54
|
+
/** Element reference */
|
|
55
|
+
el?: HTMLElement;
|
|
56
|
+
/** Classes for individual items */
|
|
57
|
+
classItem?: string;
|
|
58
|
+
/** Classes for active items */
|
|
59
|
+
classItemActive?: string;
|
|
60
|
+
/** Classes for icons */
|
|
61
|
+
classIcon?: string;
|
|
62
|
+
/** Classes for labels */
|
|
63
|
+
classLabel?: string;
|
|
64
|
+
/** Classes for children container */
|
|
65
|
+
classChildren?: string;
|
|
66
|
+
/** Classes for chevron icon */
|
|
67
|
+
classChevron?: string;
|
|
68
|
+
}
|
|
69
|
+
export declare const TREE_BASE_CLASSES = "stuic-tree";
|
|
70
|
+
export declare const TREE_ITEM_CLASSES = "stuic-tree-item";
|
|
71
|
+
export declare const TREE_CHILDREN_CLASSES = "stuic-tree-children";
|
|
72
|
+
declare function $$render<T = unknown>(): {
|
|
73
|
+
props: Props<T>;
|
|
74
|
+
exports: {};
|
|
75
|
+
bindings: "el";
|
|
76
|
+
slots: {};
|
|
77
|
+
events: {};
|
|
78
|
+
};
|
|
79
|
+
declare class __sveltets_Render<T = unknown> {
|
|
80
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
81
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
82
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
83
|
+
bindings(): "el";
|
|
84
|
+
exports(): {};
|
|
85
|
+
}
|
|
86
|
+
interface $$IsomorphicComponent {
|
|
87
|
+
new <T = unknown>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
88
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
89
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
90
|
+
<T = unknown>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
91
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
92
|
+
}
|
|
93
|
+
declare const Tree: $$IsomorphicComponent;
|
|
94
|
+
type Tree<T = unknown> = InstanceType<typeof Tree<T>>;
|
|
95
|
+
export default Tree;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/* =============================================================================
|
|
2
|
+
TREE COMPONENT TOKENS
|
|
3
|
+
Override globally: :root { --stuic-tree-indent: 1.5rem; }
|
|
4
|
+
Override locally: <Tree style="--stuic-tree-indent: 2rem;">
|
|
5
|
+
============================================================================= */
|
|
6
|
+
|
|
7
|
+
:root {
|
|
8
|
+
/* Structure tokens */
|
|
9
|
+
--stuic-tree-transition: 150ms;
|
|
10
|
+
|
|
11
|
+
/* Indentation per depth level */
|
|
12
|
+
--stuic-tree-indent: 1.25rem;
|
|
13
|
+
|
|
14
|
+
/* Item sizing */
|
|
15
|
+
--stuic-tree-item-padding-x: 0.375rem;
|
|
16
|
+
--stuic-tree-item-padding-y: 0.125rem;
|
|
17
|
+
--stuic-tree-item-height: 1.75rem;
|
|
18
|
+
--stuic-tree-item-font-size: var(--text-sm);
|
|
19
|
+
--stuic-tree-item-radius: var(--radius-sm);
|
|
20
|
+
--stuic-tree-item-gap: 0.25rem;
|
|
21
|
+
|
|
22
|
+
/* Chevron */
|
|
23
|
+
--stuic-tree-chevron-size: 14px;
|
|
24
|
+
--stuic-tree-chevron-opacity: 0.5;
|
|
25
|
+
|
|
26
|
+
/* Icon */
|
|
27
|
+
--stuic-tree-icon-opacity: 0.7;
|
|
28
|
+
|
|
29
|
+
/* Drag and drop */
|
|
30
|
+
--stuic-tree-item-opacity-dragging: 0.4;
|
|
31
|
+
--stuic-tree-drop-indicator-color: var(--stuic-color-primary);
|
|
32
|
+
--stuic-tree-drop-indicator-height: 2px;
|
|
33
|
+
--stuic-tree-item-bg-dragover: rgb(0 0 0 / 0.04);
|
|
34
|
+
|
|
35
|
+
/* Color tokens: Base */
|
|
36
|
+
--stuic-tree-item-bg: transparent;
|
|
37
|
+
--stuic-tree-item-text: inherit;
|
|
38
|
+
|
|
39
|
+
/* Color tokens: Hover */
|
|
40
|
+
--stuic-tree-item-bg-hover: rgb(0 0 0 / 0.06);
|
|
41
|
+
--stuic-tree-item-text-hover: inherit;
|
|
42
|
+
|
|
43
|
+
/* Color tokens: Active/Selected */
|
|
44
|
+
--stuic-tree-item-bg-active: var(--stuic-color-primary);
|
|
45
|
+
--stuic-tree-item-text-active: var(--stuic-color-primary-foreground);
|
|
46
|
+
|
|
47
|
+
/* Color tokens: Focused (keyboard) */
|
|
48
|
+
--stuic-tree-item-bg-focus: rgb(0 0 0 / 0.06);
|
|
49
|
+
--stuic-tree-item-text-focus: inherit;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
:root.dark {
|
|
53
|
+
--stuic-tree-item-bg-hover: rgb(255 255 255 / 0.08);
|
|
54
|
+
--stuic-tree-item-bg-focus: rgb(255 255 255 / 0.08);
|
|
55
|
+
--stuic-tree-item-bg-dragover: rgb(255 255 255 / 0.04);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@layer components {
|
|
59
|
+
/* =============================================================================
|
|
60
|
+
BASE CONTAINER
|
|
61
|
+
============================================================================= */
|
|
62
|
+
|
|
63
|
+
.stuic-tree {
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* =============================================================================
|
|
69
|
+
CHILDREN CONTAINER
|
|
70
|
+
============================================================================= */
|
|
71
|
+
|
|
72
|
+
.stuic-tree-children {
|
|
73
|
+
display: flex;
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* =============================================================================
|
|
78
|
+
ITEM STYLES
|
|
79
|
+
============================================================================= */
|
|
80
|
+
|
|
81
|
+
.stuic-tree-item {
|
|
82
|
+
/* Layout */
|
|
83
|
+
display: flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
gap: var(--stuic-tree-item-gap);
|
|
86
|
+
width: 100%;
|
|
87
|
+
min-height: var(--stuic-tree-item-height);
|
|
88
|
+
|
|
89
|
+
/* Padding */
|
|
90
|
+
padding: var(--stuic-tree-item-padding-y) var(--stuic-tree-item-padding-x);
|
|
91
|
+
|
|
92
|
+
/* Typography */
|
|
93
|
+
font-size: var(--stuic-tree-item-font-size);
|
|
94
|
+
text-align: left;
|
|
95
|
+
line-height: 1.2;
|
|
96
|
+
|
|
97
|
+
/* Visual */
|
|
98
|
+
border-radius: var(--stuic-tree-item-radius);
|
|
99
|
+
background: var(--stuic-tree-item-bg);
|
|
100
|
+
color: var(--stuic-tree-item-text);
|
|
101
|
+
border: none;
|
|
102
|
+
|
|
103
|
+
/* Interaction */
|
|
104
|
+
cursor: pointer;
|
|
105
|
+
user-select: none;
|
|
106
|
+
-webkit-tap-highlight-color: transparent;
|
|
107
|
+
transition:
|
|
108
|
+
background var(--stuic-tree-transition),
|
|
109
|
+
color var(--stuic-tree-transition);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* Chevron opacity */
|
|
113
|
+
.stuic-tree-item > span:first-child {
|
|
114
|
+
opacity: var(--stuic-tree-chevron-opacity);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* Icon opacity (second span when present) */
|
|
118
|
+
.stuic-tree-item > span:nth-child(2) {
|
|
119
|
+
opacity: var(--stuic-tree-icon-opacity);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* =============================================================================
|
|
123
|
+
ITEM STATE STYLES
|
|
124
|
+
============================================================================= */
|
|
125
|
+
|
|
126
|
+
/* Hover state */
|
|
127
|
+
.stuic-tree-item:hover:not([data-active]) {
|
|
128
|
+
background: var(--stuic-tree-item-bg-hover);
|
|
129
|
+
color: var(--stuic-tree-item-text-hover);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Focus styles */
|
|
133
|
+
.stuic-tree-item:focus {
|
|
134
|
+
outline: none;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.stuic-tree-item:focus-visible:not([data-active]) {
|
|
138
|
+
background: var(--stuic-tree-item-bg-focus);
|
|
139
|
+
color: var(--stuic-tree-item-text-focus);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* Active/Selected state */
|
|
143
|
+
.stuic-tree-item[data-active] {
|
|
144
|
+
background: var(--stuic-tree-item-bg-active);
|
|
145
|
+
color: var(--stuic-tree-item-text-active);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* Active state: full opacity on chevron and icon */
|
|
149
|
+
.stuic-tree-item[data-active] > span:first-child,
|
|
150
|
+
.stuic-tree-item[data-active] > span:nth-child(2) {
|
|
151
|
+
opacity: 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/* Focused state (keyboard navigation) */
|
|
155
|
+
.stuic-tree-item[data-focused]:not([data-active]) {
|
|
156
|
+
background: var(--stuic-tree-item-bg-focus);
|
|
157
|
+
color: var(--stuic-tree-item-text-focus);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* =============================================================================
|
|
161
|
+
DRAG AND DROP
|
|
162
|
+
============================================================================= */
|
|
163
|
+
|
|
164
|
+
.stuic-tree-item[data-dragging] {
|
|
165
|
+
opacity: var(--stuic-tree-item-opacity-dragging);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
[role="treeitem"][data-drop-position="before"] > .stuic-tree-item,
|
|
169
|
+
[role="treeitem"][data-drop-position="after"] > .stuic-tree-item {
|
|
170
|
+
border-radius: 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
[role="treeitem"][data-drop-position="before"] > .stuic-tree-item {
|
|
174
|
+
box-shadow: 0 var(--stuic-tree-drop-indicator-height) 0 0
|
|
175
|
+
var(--stuic-tree-drop-indicator-color) inset;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
[role="treeitem"][data-drop-position="after"] > .stuic-tree-item {
|
|
179
|
+
box-shadow: 0 calc(-1 * var(--stuic-tree-drop-indicator-height)) 0 0
|
|
180
|
+
var(--stuic-tree-drop-indicator-color) inset;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
[role="treeitem"][data-drop-position="inside"] > .stuic-tree-item {
|
|
184
|
+
background: var(--stuic-tree-item-bg-dragover);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Tree, TREE_BASE_CLASSES, TREE_ITEM_CLASSES, TREE_CHILDREN_CLASSES, } from "./Tree.svelte";
|
package/dist/index.css
CHANGED
|
@@ -57,6 +57,7 @@ In practice:
|
|
|
57
57
|
@import "./components/Switch/index.css";
|
|
58
58
|
@import "./components/TabbedMenu/index.css";
|
|
59
59
|
@import "./components/ThemePreview/index.css";
|
|
60
|
+
@import "./components/Tree/index.css";
|
|
60
61
|
@import "./components/TwCheck/index.css";
|
|
61
62
|
@import "./components/WithSidePanel/index.css";
|
|
62
63
|
@import "./components/X/index.css";
|
package/dist/index.d.ts
CHANGED
|
@@ -62,6 +62,7 @@ export * from "./components/Switch/index.js";
|
|
|
62
62
|
export * from "./components/TabbedMenu/index.js";
|
|
63
63
|
export * from "./components/Thc/index.js";
|
|
64
64
|
export * from "./components/ThemePreview/index.js";
|
|
65
|
+
export * from "./components/Tree/index.js";
|
|
65
66
|
export * from "./components/TwCheck/index.js";
|
|
66
67
|
export * from "./components/TypeaheadInput/index.js";
|
|
67
68
|
export * from "./components/WithSidePanel/index.js";
|
package/dist/index.js
CHANGED
|
@@ -63,6 +63,7 @@ export * from "./components/Switch/index.js";
|
|
|
63
63
|
export * from "./components/TabbedMenu/index.js";
|
|
64
64
|
export * from "./components/Thc/index.js";
|
|
65
65
|
export * from "./components/ThemePreview/index.js";
|
|
66
|
+
export * from "./components/Tree/index.js";
|
|
66
67
|
export * from "./components/TwCheck/index.js";
|
|
67
68
|
export * from "./components/TypeaheadInput/index.js";
|
|
68
69
|
export * from "./components/WithSidePanel/index.js";
|
package/package.json
CHANGED
|
@@ -1,98 +1,101 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
2
|
+
"name": "@marianmeres/stuic",
|
|
3
|
+
"version": "3.46.0",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"dev": "vite dev",
|
|
6
|
+
"build": "vite build && pnpm run prepack",
|
|
7
|
+
"preview": "vite preview",
|
|
8
|
+
"prepare": "svelte-kit sync || echo ''",
|
|
9
|
+
"prepack": "svelte-kit sync && svelte-package && publint",
|
|
10
|
+
"package": "pnpm run prepack",
|
|
11
|
+
"package:watch": "svelte-kit sync && svelte-package --watch && publint",
|
|
12
|
+
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
|
13
|
+
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
|
14
|
+
"format": "prettier --write .",
|
|
15
|
+
"lint": "eslint . && prettier --check .",
|
|
16
|
+
"test": "vitest --dir src/",
|
|
17
|
+
"svelte-check": "svelte-check",
|
|
18
|
+
"svelte-package": "svelte-package",
|
|
19
|
+
"rp": "pnpm run build && ./release.sh patch",
|
|
20
|
+
"rpm": "pnpm run build && ./release.sh minor",
|
|
21
|
+
"build:theme": "tsx scripts/generate-theme.ts",
|
|
22
|
+
"build:theme:all": "pnpm run build:theme --indir=src/lib/themes --outdir=src/lib/themes/css"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"!dist/**/*.test.*",
|
|
27
|
+
"!dist/**/*.spec.*",
|
|
28
|
+
"docs",
|
|
29
|
+
"AGENTS.md",
|
|
30
|
+
"CLAUDE.md",
|
|
31
|
+
"API.md"
|
|
32
|
+
],
|
|
33
|
+
"sideEffects": [
|
|
34
|
+
"**/*.css"
|
|
35
|
+
],
|
|
36
|
+
"svelte": "./dist/index.js",
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"type": "module",
|
|
39
|
+
"exports": {
|
|
40
|
+
".": {
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"svelte": "./dist/index.js"
|
|
43
|
+
},
|
|
44
|
+
"./utils": {
|
|
45
|
+
"types": "./dist/utils/index.d.ts",
|
|
46
|
+
"default": "./dist/utils/index.js"
|
|
47
|
+
},
|
|
48
|
+
"./themes/*": {
|
|
49
|
+
"types": "./dist/themes/*.d.ts",
|
|
50
|
+
"default": "./dist/themes/*.js"
|
|
51
|
+
},
|
|
52
|
+
"./themes/css/*": "./dist/themes/css/*",
|
|
53
|
+
"./phone-validation": {
|
|
54
|
+
"types": "./dist/components/Input/phone-validation.d.ts",
|
|
55
|
+
"default": "./dist/components/Input/phone-validation.js"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"svelte": "^5.0.0"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@eslint/js": "^9.39.4",
|
|
63
|
+
"@marianmeres/random-human-readable": "^1.6.1",
|
|
64
|
+
"@sveltejs/adapter-auto": "^4.0.0",
|
|
65
|
+
"@sveltejs/kit": "^2.53.4",
|
|
66
|
+
"@sveltejs/package": "^2.5.7",
|
|
67
|
+
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
68
|
+
"@tailwindcss/cli": "^4.2.1",
|
|
69
|
+
"@tailwindcss/forms": "^0.5.11",
|
|
70
|
+
"@tailwindcss/typography": "^0.5.19",
|
|
71
|
+
"@tailwindcss/vite": "^4.2.1",
|
|
72
|
+
"@types/node": "^25.4.0",
|
|
73
|
+
"dotenv": "^16.6.1",
|
|
74
|
+
"eslint": "^9.39.4",
|
|
75
|
+
"globals": "^16.5.0",
|
|
76
|
+
"prettier": "^3.8.1",
|
|
77
|
+
"prettier-plugin-svelte": "^3.5.1",
|
|
78
|
+
"publint": "^0.3.18",
|
|
79
|
+
"svelte": "^5.53.9",
|
|
80
|
+
"svelte-check": "^4.4.5",
|
|
81
|
+
"tailwindcss": "^4.2.1",
|
|
82
|
+
"tsx": "^4.21.0",
|
|
83
|
+
"typescript": "^5.9.3",
|
|
84
|
+
"typescript-eslint": "^8.57.0",
|
|
85
|
+
"vite": "^7.3.1",
|
|
86
|
+
"vitest": "^3.2.4"
|
|
87
|
+
},
|
|
88
|
+
"dependencies": {
|
|
89
|
+
"@marianmeres/clog": "^3.15.2",
|
|
90
|
+
"@marianmeres/icons-fns": "^5.0.0",
|
|
91
|
+
"@marianmeres/item-collection": "^1.3.5",
|
|
92
|
+
"@marianmeres/paging-store": "^2.0.2",
|
|
93
|
+
"@marianmeres/parse-boolean": "^2.0.5",
|
|
94
|
+
"@marianmeres/ticker": "^1.16.5",
|
|
95
|
+
"@marianmeres/tree": "^2.2.5",
|
|
96
|
+
"esm-env": "^1.2.2",
|
|
97
|
+
"libphonenumber-js": "^1.12.39",
|
|
98
|
+
"runed": "^0.23.4",
|
|
99
|
+
"tailwind-merge": "^3.5.0"
|
|
100
|
+
}
|
|
101
|
+
}
|