@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.
@@ -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 */
@@ -147,7 +147,6 @@
147
147
  /* Layout */
148
148
  min-width: var(--stuic-dropdown-menu-min-width);
149
149
  overflow-y: auto;
150
- scrollbar-gutter: stable;
151
150
  scrollbar-width: thin;
152
151
 
153
152
  /* Stacking */
@@ -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,2 @@
1
+ export { default as Tree, type Props as TreeProps, type TreeMoveEvent, type TreeDropPosition, TREE_BASE_CLASSES, TREE_ITEM_CLASSES, TREE_CHILDREN_CLASSES, } from "./Tree.svelte";
2
+ export type { TreeNodeDTO } from "@marianmeres/tree";
@@ -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
- "name": "@marianmeres/stuic",
3
- "version": "3.45.1",
4
- "files": [
5
- "dist",
6
- "!dist/**/*.test.*",
7
- "!dist/**/*.spec.*",
8
- "docs",
9
- "AGENTS.md",
10
- "CLAUDE.md",
11
- "API.md"
12
- ],
13
- "sideEffects": [
14
- "**/*.css"
15
- ],
16
- "svelte": "./dist/index.js",
17
- "types": "./dist/index.d.ts",
18
- "type": "module",
19
- "exports": {
20
- ".": {
21
- "types": "./dist/index.d.ts",
22
- "svelte": "./dist/index.js"
23
- },
24
- "./utils": {
25
- "types": "./dist/utils/index.d.ts",
26
- "default": "./dist/utils/index.js"
27
- },
28
- "./themes/*": {
29
- "types": "./dist/themes/*.d.ts",
30
- "default": "./dist/themes/*.js"
31
- },
32
- "./themes/css/*": "./dist/themes/css/*",
33
- "./phone-validation": {
34
- "types": "./dist/components/Input/phone-validation.d.ts",
35
- "default": "./dist/components/Input/phone-validation.js"
36
- }
37
- },
38
- "peerDependencies": {
39
- "svelte": "^5.0.0"
40
- },
41
- "devDependencies": {
42
- "@eslint/js": "^9.39.4",
43
- "@marianmeres/random-human-readable": "^1.6.1",
44
- "@sveltejs/adapter-auto": "^4.0.0",
45
- "@sveltejs/kit": "^2.53.4",
46
- "@sveltejs/package": "^2.5.7",
47
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
48
- "@tailwindcss/cli": "^4.2.1",
49
- "@tailwindcss/forms": "^0.5.11",
50
- "@tailwindcss/typography": "^0.5.19",
51
- "@tailwindcss/vite": "^4.2.1",
52
- "@types/node": "^25.4.0",
53
- "dotenv": "^16.6.1",
54
- "eslint": "^9.39.4",
55
- "globals": "^16.5.0",
56
- "prettier": "^3.8.1",
57
- "prettier-plugin-svelte": "^3.5.1",
58
- "publint": "^0.3.18",
59
- "svelte": "^5.53.8",
60
- "svelte-check": "^4.4.5",
61
- "tailwindcss": "^4.2.1",
62
- "tsx": "^4.21.0",
63
- "typescript": "^5.9.3",
64
- "typescript-eslint": "^8.57.0",
65
- "vite": "^7.3.1",
66
- "vitest": "^3.2.4"
67
- },
68
- "dependencies": {
69
- "@marianmeres/clog": "^3.15.2",
70
- "@marianmeres/icons-fns": "^5.0.0",
71
- "@marianmeres/item-collection": "^1.3.5",
72
- "@marianmeres/paging-store": "^2.0.2",
73
- "@marianmeres/parse-boolean": "^2.0.5",
74
- "@marianmeres/ticker": "^1.16.5",
75
- "esm-env": "^1.2.2",
76
- "libphonenumber-js": "^1.12.39",
77
- "runed": "^0.23.4",
78
- "tailwind-merge": "^3.5.0"
79
- },
80
- "scripts": {
81
- "dev": "vite dev",
82
- "build": "vite build && pnpm run prepack",
83
- "preview": "vite preview",
84
- "package": "pnpm run prepack",
85
- "package:watch": "svelte-kit sync && svelte-package --watch && publint",
86
- "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
87
- "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
88
- "format": "prettier --write .",
89
- "lint": "eslint . && prettier --check .",
90
- "test": "vitest --dir src/",
91
- "svelte-check": "svelte-check",
92
- "svelte-package": "svelte-package",
93
- "rp": "pnpm run build && ./release.sh patch",
94
- "rpm": "pnpm run build && ./release.sh minor",
95
- "build:theme": "tsx scripts/generate-theme.ts",
96
- "build:theme:all": "pnpm run build:theme --indir=src/lib/themes --outdir=src/lib/themes/css"
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
+ }