@warkypublic/svelix 0.1.47 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/components/Boxer/Boxer.svelte +16 -1
  2. package/dist/components/Boxer/types.d.ts +2 -0
  3. package/dist/components/CardGrid/CardGrid.svelte +50 -4
  4. package/dist/components/CardGrid/CardGrid.svelte.d.ts +4 -0
  5. package/dist/components/CardGrid/DefaultCard.svelte +7 -2
  6. package/dist/components/CardGrid/DefaultCard.svelte.d.ts +1 -0
  7. package/dist/components/CardGrid/cardGrid.d.ts +24 -0
  8. package/dist/components/CardGrid/cardGrid.js +20 -0
  9. package/dist/components/Former/FormerDrawer.svelte +10 -22
  10. package/dist/components/Former/FormerModal.svelte +10 -22
  11. package/dist/components/Former/formerOverlayHelpers.svelte.d.ts +11 -0
  12. package/dist/components/Former/formerOverlayHelpers.svelte.js +38 -0
  13. package/dist/components/Former/index.d.ts +1 -0
  14. package/dist/components/Former/index.js +1 -0
  15. package/dist/components/FormerControllers/ButtonCtrl.svelte +11 -16
  16. package/dist/components/FormerControllers/CtrlFieldShell.svelte +15 -0
  17. package/dist/components/FormerControllers/CtrlFieldShell.svelte.d.ts +12 -0
  18. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrl.svelte +6 -13
  19. package/dist/components/FormerControllers/IconButtonCtrl.svelte +11 -16
  20. package/dist/components/FormerControllers/NativeSelectCtrl.svelte +7 -13
  21. package/dist/components/FormerControllers/NumberInputCtrl.svelte +18 -14
  22. package/dist/components/FormerControllers/PasswordInputCtrl.svelte +7 -13
  23. package/dist/components/FormerControllers/SwitchCtrl.svelte +4 -3
  24. package/dist/components/FormerControllers/TextAreaCtrl.svelte +7 -13
  25. package/dist/components/FormerControllers/TextInputCtrl.svelte +7 -13
  26. package/dist/components/FormerControllers/ctrlLoading.svelte.d.ts +8 -0
  27. package/dist/components/FormerControllers/ctrlLoading.svelte.js +43 -0
  28. package/dist/components/FormerControllers/index.d.ts +2 -0
  29. package/dist/components/FormerControllers/index.js +2 -0
  30. package/dist/components/FormerControllers/utils.d.ts +1 -0
  31. package/dist/components/FormerControllers/utils.js +3 -0
  32. package/dist/components/Gridler/components/Gridler.svelte +10 -4
  33. package/dist/components/Gridler/components/GridlerFull.svelte +2 -1
  34. package/dist/components/Gridler/utils/serverPaging.d.ts +1 -0
  35. package/dist/components/Gridler/utils/serverPaging.js +7 -0
  36. package/dist/components/VTree/VTree.svelte +107 -2
  37. package/dist/components/VTree/VTree.utils.d.ts +23 -0
  38. package/dist/components/VTree/VTree.utils.js +73 -0
  39. package/dist/components/VTree/VTreeControlledSelectionDemo.svelte +20 -0
  40. package/dist/components/VTree/VTreeControlledSelectionDemo.svelte.d.ts +18 -0
  41. package/dist/components/VTree/VTreeCustomizationDemo.svelte +23 -0
  42. package/dist/components/VTree/VTreeCustomizationDemo.svelte.d.ts +18 -0
  43. package/dist/components/VTree/VTreeInteractionDemo.svelte +29 -0
  44. package/dist/components/VTree/VTreeInteractionDemo.svelte.d.ts +18 -0
  45. package/dist/components/VTree/VTreeMultiSelectDemo.svelte +27 -0
  46. package/dist/components/VTree/VTreeMultiSelectDemo.svelte.d.ts +18 -0
  47. package/dist/components/VTree/VTreeRow.svelte +71 -57
  48. package/dist/components/VTree/VTreeRow.svelte.d.ts +34 -18
  49. package/dist/components/VTree/VTreeVirtualViewport.svelte +20 -40
  50. package/dist/components/VTree/VTreeVirtualViewport.svelte.d.ts +35 -18
  51. package/dist/components/VTree/types.d.ts +39 -1
  52. package/llm/COMPONENT_GUIDE.md +25 -0
  53. package/package.json +45 -45
@@ -1,25 +1,32 @@
1
- <script lang="ts">let { disabled, error, label, max, min, name, onchange, placeholder, required, sid, step, tooltip, value = $bindable(undefined) } = $props();
2
- const id = $derived(`field_${name ?? ""}_${sid ?? ""}`);
1
+ <script lang="ts">import CtrlFieldShell from "./CtrlFieldShell.svelte";
2
+ import { makeFieldId } from "./utils";
3
+ let { disabled, error, label, max, min, name, onchange, placeholder, required, sid, step, tooltip, value = $bindable(undefined) } = $props();
4
+ const id = $derived(makeFieldId(name, sid));
3
5
  function handleInput(e) {
4
6
  const target = e.target;
5
- const parsed = target.value !== "" ? Number(target.value) : undefined;
7
+ const raw = target.value;
8
+ if (raw === "") {
9
+ value = undefined;
10
+ onchange?.(undefined);
11
+ return;
12
+ }
13
+ const parsed = Number(raw);
14
+ // Intermediate typed states (e.g. "-", "1e") parse to NaN; ignore until valid.
15
+ if (Number.isNaN(parsed)) {
16
+ return;
17
+ }
6
18
  value = parsed;
7
19
  onchange?.(parsed);
8
20
  }
9
- export {};
10
21
  </script>
11
22
 
12
- <div class="flex flex-col gap-1" title={tooltip}>
13
- {#if label}
14
- <label class="label text-sm font-medium" for={id}>
15
- {label}{#if required}<span class="text-error-500">*</span>{/if}
16
- </label>
17
- {/if}
23
+ <CtrlFieldShell {error} {id} {label} {required} {tooltip}>
18
24
  <input
19
25
  {id}
20
26
  {disabled}
21
27
  {max}
22
28
  {min}
29
+ {name}
23
30
  {placeholder}
24
31
  {required}
25
32
  {step}
@@ -29,7 +36,4 @@ export {};
29
36
  value={value ?? ''}
30
37
  oninput={handleInput}
31
38
  />
32
- {#if error}
33
- <p class="text-error-500 text-xs">{error}</p>
34
- {/if}
35
- </div>
39
+ </CtrlFieldShell>
@@ -1,19 +1,16 @@
1
- <script lang="ts">let { disabled, error, label, name, onchange, placeholder, required, sid, tooltip, value = $bindable("") } = $props();
2
- const id = $derived(`field_${name ?? ""}_${sid ?? ""}`);
1
+ <script lang="ts">import CtrlFieldShell from "./CtrlFieldShell.svelte";
2
+ import { makeFieldId } from "./utils";
3
+ let { disabled, error, label, name, onchange, placeholder, required, sid, tooltip, value = $bindable("") } = $props();
4
+ const id = $derived(makeFieldId(name, sid));
3
5
  let showPassword = $state(false);
4
- export {};
5
6
  </script>
6
7
 
7
- <div class="flex flex-col gap-1" title={tooltip}>
8
- {#if label}
9
- <label class="label text-sm font-medium" for={id}>
10
- {label}{#if required}<span class="text-error-500">*</span>{/if}
11
- </label>
12
- {/if}
8
+ <CtrlFieldShell {error} {id} {label} {required} {tooltip}>
13
9
  <div class="flex gap-0">
14
10
  <input
15
11
  {id}
16
12
  {disabled}
13
+ {name}
17
14
  {placeholder}
18
15
  {required}
19
16
  bind:value
@@ -32,7 +29,4 @@ export {};
32
29
  {showPassword ? '🙈' : '👁'}
33
30
  </button>
34
31
  </div>
35
- {#if error}
36
- <p class="text-error-500 text-xs">{error}</p>
37
- {/if}
38
- </div>
32
+ </CtrlFieldShell>
@@ -1,12 +1,13 @@
1
- <script lang="ts">let { checked = $bindable(false), disabled, error, label, name, onchange, sid, tooltip } = $props();
2
- const id = $derived(`field_${name ?? ""}_${sid ?? ""}`);
3
- export {};
1
+ <script lang="ts">import { makeFieldId } from "./utils";
2
+ let { checked = $bindable(false), disabled, error, label, name, onchange, sid, tooltip } = $props();
3
+ const id = $derived(makeFieldId(name, sid));
4
4
  </script>
5
5
 
6
6
  <div class="flex items-center gap-2" title={tooltip}>
7
7
  <input
8
8
  {id}
9
9
  {disabled}
10
+ {name}
10
11
  bind:checked
11
12
  class="checkbox"
12
13
  type="checkbox"
@@ -1,17 +1,14 @@
1
- <script lang="ts">let { disabled, error, label, name, onchange, placeholder, required, rows = 4, sid, tooltip, value = $bindable("") } = $props();
2
- const id = $derived(`field_${name ?? ""}_${sid ?? ""}`);
3
- export {};
1
+ <script lang="ts">import CtrlFieldShell from "./CtrlFieldShell.svelte";
2
+ import { makeFieldId } from "./utils";
3
+ let { disabled, error, label, name, onchange, placeholder, required, rows = 4, sid, tooltip, value = $bindable("") } = $props();
4
+ const id = $derived(makeFieldId(name, sid));
4
5
  </script>
5
6
 
6
- <div class="flex flex-col gap-1" title={tooltip}>
7
- {#if label}
8
- <label class="label text-sm font-medium" for={id}>
9
- {label}{#if required}<span class="text-error-500">*</span>{/if}
10
- </label>
11
- {/if}
7
+ <CtrlFieldShell {error} {id} {label} {required} {tooltip}>
12
8
  <textarea
13
9
  {id}
14
10
  {disabled}
11
+ {name}
15
12
  {placeholder}
16
13
  {required}
17
14
  {rows}
@@ -20,7 +17,4 @@ export {};
20
17
  class:textarea-error={!!error}
21
18
  oninput={() => onchange?.(value)}
22
19
  ></textarea>
23
- {#if error}
24
- <p class="text-error-500 text-xs">{error}</p>
25
- {/if}
26
- </div>
20
+ </CtrlFieldShell>
@@ -1,17 +1,14 @@
1
- <script lang="ts">let { disabled, error, label, name, onchange, placeholder, required, sid, tooltip, type = "text", value = $bindable("") } = $props();
2
- const id = $derived(`field_${name ?? ""}_${sid ?? ""}`);
3
- export {};
1
+ <script lang="ts">import CtrlFieldShell from "./CtrlFieldShell.svelte";
2
+ import { makeFieldId } from "./utils";
3
+ let { disabled, error, label, name, onchange, placeholder, required, sid, tooltip, type = "text", value = $bindable("") } = $props();
4
+ const id = $derived(makeFieldId(name, sid));
4
5
  </script>
5
6
 
6
- <div class="flex flex-col gap-1" title={tooltip}>
7
- {#if label}
8
- <label class="label text-sm font-medium" for={id}>
9
- {label}{#if required}<span class="text-error-500">*</span>{/if}
10
- </label>
11
- {/if}
7
+ <CtrlFieldShell {error} {id} {label} {required} {tooltip}>
12
8
  <input
13
9
  {id}
14
10
  {disabled}
11
+ {name}
15
12
  {placeholder}
16
13
  {required}
17
14
  {type}
@@ -20,7 +17,4 @@ export {};
20
17
  class:input-error={!!error}
21
18
  oninput={() => onchange?.(value)}
22
19
  />
23
- {#if error}
24
- <p class="text-error-500 text-xs">{error}</p>
25
- {/if}
26
- </div>
20
+ </CtrlFieldShell>
@@ -0,0 +1,8 @@
1
+ export declare function createClickLoading(getExternalLoading: () => boolean | undefined): {
2
+ readonly isLoading: boolean;
3
+ run: (action?: () => Promise<void>) => Promise<void>;
4
+ };
5
+ export declare function createClickPulse(durationMs?: number): {
6
+ readonly pressed: boolean;
7
+ trigger: () => void;
8
+ };
@@ -0,0 +1,43 @@
1
+ export function createClickLoading(getExternalLoading) {
2
+ let internalLoading = $state(false);
3
+ const isLoading = $derived(internalLoading || (getExternalLoading() ?? false));
4
+ async function run(action) {
5
+ if (!action || isLoading)
6
+ return;
7
+ internalLoading = true;
8
+ try {
9
+ await action();
10
+ }
11
+ finally {
12
+ internalLoading = false;
13
+ }
14
+ }
15
+ return {
16
+ get isLoading() {
17
+ return isLoading;
18
+ },
19
+ run,
20
+ };
21
+ }
22
+ // Guarantees a visible "click" pulse even when the action resolves faster than
23
+ // a human can perceive the loading spinner — decoupled from actual duration.
24
+ export function createClickPulse(durationMs = 200) {
25
+ let pressed = $state(false);
26
+ let timeout;
27
+ function trigger() {
28
+ pressed = true;
29
+ clearTimeout(timeout);
30
+ timeout = setTimeout(() => {
31
+ pressed = false;
32
+ }, durationMs);
33
+ }
34
+ $effect(() => {
35
+ return () => clearTimeout(timeout);
36
+ });
37
+ return {
38
+ get pressed() {
39
+ return pressed;
40
+ },
41
+ trigger,
42
+ };
43
+ }
@@ -1,4 +1,5 @@
1
1
  export { default as ButtonCtrl } from './ButtonCtrl.svelte';
2
+ export { default as CtrlFieldShell } from './CtrlFieldShell.svelte';
2
3
  export { DateTimeCtrl } from './DateTimeCtrl/index';
3
4
  export { default as IconButtonCtrl } from './IconButtonCtrl.svelte';
4
5
  export { default as InlineWrapper } from './InlineWrapper.svelte';
@@ -8,5 +9,6 @@ export { default as PasswordInputCtrl } from './PasswordInputCtrl.svelte';
8
9
  export { default as SwitchCtrl } from './SwitchCtrl.svelte';
9
10
  export { default as TextAreaCtrl } from './TextAreaCtrl.svelte';
10
11
  export { default as TextInputCtrl } from './TextInputCtrl.svelte';
12
+ export { makeFieldId } from './utils';
11
13
  export type { DateTimeConstraints, DateTimeMode, DateTimeParts } from './DateTimeCtrl/DateTimeCtrl.utils';
12
14
  export * from './types';
@@ -1,4 +1,5 @@
1
1
  export { default as ButtonCtrl } from './ButtonCtrl.svelte';
2
+ export { default as CtrlFieldShell } from './CtrlFieldShell.svelte';
2
3
  export { DateTimeCtrl } from './DateTimeCtrl/index';
3
4
  export { default as IconButtonCtrl } from './IconButtonCtrl.svelte';
4
5
  export { default as InlineWrapper } from './InlineWrapper.svelte';
@@ -8,4 +9,5 @@ export { default as PasswordInputCtrl } from './PasswordInputCtrl.svelte';
8
9
  export { default as SwitchCtrl } from './SwitchCtrl.svelte';
9
10
  export { default as TextAreaCtrl } from './TextAreaCtrl.svelte';
10
11
  export { default as TextInputCtrl } from './TextInputCtrl.svelte';
12
+ export { makeFieldId } from './utils';
11
13
  export * from './types';
@@ -0,0 +1 @@
1
+ export declare function makeFieldId(name?: string, sid?: string): string;
@@ -0,0 +1,3 @@
1
+ export function makeFieldId(name, sid) {
2
+ return `field_${name ?? ''}_${sid ?? ''}`;
3
+ }
@@ -10,12 +10,18 @@ const resolvedRowMarkers = $derived(rowMarkers ?? (settings?.showRowMarkers ? se
10
10
  // ── Theme ─────────────────────────────────────────────────────────────────────
11
11
  let isDarkMode = $state(false);
12
12
  let resolvedSkeletonTheme = $state(DEFAULT_THEME);
13
+ // Both isDarkMode and resolvedSkeletonTheme are derived from a local `dark`
14
+ // value rather than read back from isDarkMode itself, so this effect never
15
+ // tracks its own write target — avoids tearing down/recreating the
16
+ // MutationObserver on an incidental extra run.
13
17
  $effect(() => {
14
- isDarkMode = detectDarkMode();
15
- resolvedSkeletonTheme = resolveSkeletonGridTheme(isDarkMode) ?? (isDarkMode ? DEFAULT_THEME_DARK : DEFAULT_THEME);
18
+ const dark = detectDarkMode();
19
+ isDarkMode = dark;
20
+ resolvedSkeletonTheme = resolveSkeletonGridTheme(dark) ?? (dark ? DEFAULT_THEME_DARK : DEFAULT_THEME);
16
21
  const mo = new MutationObserver(() => {
17
- isDarkMode = detectDarkMode();
18
- resolvedSkeletonTheme = resolveSkeletonGridTheme(isDarkMode) ?? (isDarkMode ? DEFAULT_THEME_DARK : DEFAULT_THEME);
22
+ const dark = detectDarkMode();
23
+ isDarkMode = dark;
24
+ resolvedSkeletonTheme = resolveSkeletonGridTheme(dark) ?? (dark ? DEFAULT_THEME_DARK : DEFAULT_THEME);
19
25
  });
20
26
  const opts = {
21
27
  attributes: true,
@@ -8,6 +8,7 @@ import { reorderColumns } from "../utils/columns.js";
8
8
  import { applyLocalFilter, buildSearchFilters, gridColumnFiltersToFilterOptions } from "../utils/filters.js";
9
9
  import { applyLocalSort, sortOrderToSortOptions } from "../utils/sort.js";
10
10
  import { makeGetCellContent } from "../utils/cellContent.js";
11
+ import { getServerRenderableRows } from "../utils/serverPaging.js";
11
12
  let { columns, data, getCellContent, rows, manageColumns = true, onColumnsChange, searchValue, defaultSearchValue = "", onSearchValueChange, onDataChange, dataSource, dataSourceOptions, pageSize = 200, uniqueID, serverSideSearch = false, searchColumns, onLoadError, total = $bindable(0), loading = $bindable(false), filters, onFilterChange, sortOrder, onSortOrderChange, selectedItems: _selectedItems, onSelectedItemsChange, settings, fixedColumns = 0, rowMarkers, showSearch, height = 500, onColumnMoved, onCellEdited, onCellDblClick, onRowDblClick, onRowClick, onRowContextMenu, onMenuClick, onSelectionChange, onCellEvent, onGridEvent, menuItems, onMenuItemSelect, children, ...rest } = $props();
12
13
  // ── Column management ─────────────────────────────────────────────────────
13
14
  // eslint-disable-next-line svelte/no-unused-svelte-ignore
@@ -471,7 +472,7 @@ const resolvedGetCellContent = $derived.by(() => {
471
472
  });
472
473
  // ── Row count ──────────────────────────────────────────────────────────────
473
474
  const resolvedRows = $derived.by(() => {
474
- if (isServerMode) return serverTotal;
475
+ if (isServerMode) return getServerRenderableRows(serverData.length, serverTotal, serverAllLoaded);
475
476
  if (rows != null) return rows;
476
477
  return filteredDataState.length;
477
478
  });
@@ -0,0 +1 @@
1
+ export declare function getServerRenderableRows(loadedRows: number, reportedTotal: number, allLoaded: boolean): number;
@@ -0,0 +1,7 @@
1
+ export function getServerRenderableRows(loadedRows, reportedTotal, allLoaded) {
2
+ if (allLoaded)
3
+ return reportedTotal;
4
+ if (reportedTotal <= 0)
5
+ return loadedRows;
6
+ return Math.min(loadedRows, reportedTotal);
7
+ }
@@ -2,7 +2,8 @@
2
2
  import VTreeSearch from "./VTreeSearch.svelte";
3
3
  import VTreeVirtualViewport from "./VTreeVirtualViewport.svelte";
4
4
  import VTreeContextMenu from "./VTreeContextMenu.svelte";
5
- const { data = [], adapter, idField = "id", labelField = "label", descriptionField = "description", childrenField = "children", hasChildrenField = "hasChildren", height = 420, rowHeight = 36, indent = 18, overscan = 8, searchable = true, searchPlaceholder = "Search tree...", searchValue = "", serverSideSearch = false, autoExpandSearchResults = true, loadOnMount = true, emptyMessage = "Nothing here...", loadingMessage = "Loading...", leftSection, rightSection, contextMenuItems, onselect, onclick, ondoubleclick, oncontext, ontoggle, onsearch, onloaderror } = $props();
5
+ import { buildNodeIndex, computeMultiSelectClick, findAncestorIds } from "./VTree.utils.js";
6
+ const { data = [], adapter, idField = "id", labelField = "label", descriptionField = "description", childrenField = "children", hasChildrenField = "hasChildren", height = 420, rowHeight = 36, indent = 18, overscan = 8, searchable = true, searchPlaceholder = "Search tree...", searchValue = "", serverSideSearch = false, autoExpandSearchResults = true, loadOnMount = true, emptyMessage = "Nothing here...", loadingMessage = "Loading...", leftSection, rightSection, contextMenuItems, onselect, onclick, ondoubleclick, oncontext, ontoggle, onsearch, onloaderror, selected, onSelectedChange, multiple = false, selectedItems, onSelectedItemsChange, selectionMode = "modifier", autoMeasure = false, iconField, icon, classes, showRowSeparator = true, hoverEffect = true, onsingleclick, singleClickDelay = 250, onlongpress, longPressDelay = 500, longPressMoveTolerance = 10, onrowhover, onrowhoverend } = $props();
6
7
  let viewportRef = $state(undefined);
7
8
  let focusedIndex = $state(0);
8
9
  let selectedId = $state(null);
@@ -22,6 +23,50 @@ let contextX = $state(0);
22
23
  let contextY = $state(0);
23
24
  let contextRow = $state(null);
24
25
  let contextItems = $state([]);
26
+ // Long-press is handled per row and committed here when fired.
27
+ function handleRowLongpress(index, event) {
28
+ const row = flattenedRows[index];
29
+ if (!row) return;
30
+ selectRow(index);
31
+ commitSelection(index);
32
+ onlongpress?.(row.node, treeDataSnapshot, event);
33
+ }
34
+ // Selection model state.
35
+ let internalSelected = $state(null);
36
+ let internalSelectedItems = $state([]);
37
+ let rangeAnchorIndex = $state(null);
38
+ let lastRevealedId = $state(null);
39
+ let lastScrolledId = $state(null);
40
+ let singleClickTimer;
41
+ let clickCount = 0;
42
+ function commitSelection(index, event, forceToggle = false) {
43
+ const row = flattenedRows[index];
44
+ if (!row) return;
45
+ if (multiple) {
46
+ applyMultiSelect(row.node, index, event, forceToggle);
47
+ } else {
48
+ onSelectedChange?.(row.node);
49
+ if (selected === undefined) internalSelected = row.node;
50
+ }
51
+ }
52
+ function applyMultiSelect(node, index, event, forceToggle = false) {
53
+ const currentItems = selectedItems ?? internalSelectedItems;
54
+ const orderedRowIds = flattenedRows.map((row) => row.id);
55
+ const clickedId = getNodeId(node);
56
+ const result = computeMultiSelectClick({
57
+ currentIds: currentItems.map((item) => getNodeId(item)),
58
+ clickedId,
59
+ clickedIndex: index,
60
+ anchorIndex: rangeAnchorIndex,
61
+ orderedRowIds,
62
+ ctrlOrMeta: forceToggle || Boolean(event?.ctrlKey || event?.metaKey),
63
+ shift: Boolean(event?.shiftKey)
64
+ });
65
+ rangeAnchorIndex = result.nextAnchorIndex;
66
+ const items = result.ids.map((id) => nodeIndex.byId.get(id)).filter((item) => Boolean(item));
67
+ if (selectedItems === undefined) internalSelectedItems = items;
68
+ onSelectedItemsChange?.(items);
69
+ }
25
70
  function getNodeId(node) {
26
71
  const value = node?.[idField];
27
72
  return String(value ?? "");
@@ -122,6 +167,7 @@ const displayRoots = $derived.by(() => {
122
167
  if (adapter && serverSideSearch && searchQuery.trim().length > 0) return searchNodes;
123
168
  return rootNodes;
124
169
  });
170
+ const nodeIndex = $derived.by(() => buildNodeIndex(displayRoots, getNodeId, getNodeChildren));
125
171
  const flattenedRows = $derived.by(() => {
126
172
  const rows = [];
127
173
  const activeSearch = searchQuery.trim().toLowerCase();
@@ -178,10 +224,36 @@ const treeDataSnapshot = $derived.by(() => {
178
224
  });
179
225
  return buildTree(displayRoots);
180
226
  });
227
+ const selectedNodeId = $derived(selected ? getNodeId(selected) : selected === null ? "" : internalSelected ? getNodeId(internalSelected) : "");
228
+ $effect(() => {
229
+ if (!selectedNodeId || lastRevealedId === selectedNodeId) return;
230
+ const ancestors = findAncestorIds(nodeIndex.parentById, selectedNodeId);
231
+ const next = { ...expandedById };
232
+ let changed = false;
233
+ for (const id of ancestors) {
234
+ if (!next[id]) {
235
+ next[id] = true;
236
+ changed = true;
237
+ }
238
+ }
239
+ if (changed) expandedById = next;
240
+ lastRevealedId = selectedNodeId;
241
+ lastScrolledId = null;
242
+ });
243
+ $effect(() => {
244
+ if (!selectedNodeId || lastScrolledId === selectedNodeId) return;
245
+ const index = flattenedRows.findIndex((row) => row.id === selectedNodeId);
246
+ if (index < 0) return;
247
+ focusedIndex = index;
248
+ selectedId = selectedNodeId;
249
+ viewportRef?.scrollToIndex(index);
250
+ lastScrolledId = selectedNodeId;
251
+ });
181
252
  function selectRow(index) {
182
253
  const row = flattenedRows[index];
183
254
  if (!row) return;
184
255
  focusedIndex = index;
256
+ // Keep existing uncontrolled fallback behavior.
185
257
  selectedId = row.id;
186
258
  onselect?.(row.node);
187
259
  viewportRef?.scrollToIndex(index);
@@ -247,13 +319,20 @@ function handleTreeKeydown(event) {
247
319
  }
248
320
  return;
249
321
  }
250
- if (event.key === " " || event.key === "Enter") {
322
+ if (event.key === "Enter") {
251
323
  event.preventDefault();
252
324
  selectRow(focusedIndex);
325
+ // Single-click selection via Enter.
326
+ commitSelection(focusedIndex);
253
327
  if (current.hasChildren) {
254
328
  toggleNode(current.node);
255
329
  }
256
330
  }
331
+ // Single-click selection via Space.
332
+ if (event.key === " ") {
333
+ event.preventDefault();
334
+ commitSelection(focusedIndex);
335
+ }
257
336
  }
258
337
  async function runServerSearch(query) {
259
338
  if (!adapter || !serverSideSearch) return;
@@ -281,10 +360,20 @@ function handleRowClick(index, event) {
281
360
  if (!row) return;
282
361
  selectRow(index);
283
362
  onclick?.(row.node, treeDataSnapshot, event);
363
+ // Checkbox mode intentionally leaves row-body clicks out of multi-selection.
364
+ if (!multiple || selectionMode === "modifier") commitSelection(index, event);
365
+ clearTimeout(singleClickTimer);
366
+ clickCount += 1;
367
+ singleClickTimer = setTimeout(() => {
368
+ if (clickCount === 1) onsingleclick?.(row.node, treeDataSnapshot, event);
369
+ clickCount = 0;
370
+ }, singleClickDelay);
284
371
  }
285
372
  function handleRowDoubleClick(index, event) {
286
373
  const row = flattenedRows[index];
287
374
  if (!row) return;
375
+ clearTimeout(singleClickTimer);
376
+ clickCount = 0;
288
377
  selectRow(index);
289
378
  ondoubleclick?.(row.node, treeDataSnapshot, event);
290
379
  }
@@ -358,6 +447,7 @@ onMount(() => {
358
447
  bind:this={viewportRef}
359
448
  rows={flattenedRows}
360
449
  {rowHeight}
450
+ {autoMeasure}
361
451
  {height}
362
452
  {indent}
363
453
  {overscan}
@@ -371,12 +461,27 @@ onMount(() => {
371
461
  {treeDataSnapshot}
372
462
  {leftSection}
373
463
  {rightSection}
464
+ {iconField}
465
+ {icon}
466
+ {classes}
467
+ {showRowSeparator}
468
+ {hoverEffect}
469
+ {multiple}
470
+ {selectionMode}
471
+ selectedItemIds={(selectedItems ?? internalSelectedItems).map((item) => getNodeId(item))}
472
+ selectedNodeId={selectedNodeId}
374
473
  onselectrow={handleRowClick}
375
474
  onrowdoubleclick={handleRowDoubleClick}
376
475
  onrowcontext={handleRowContext}
377
476
  onviewportcontext={handleViewportContext}
378
477
  ontogglerow={toggleNode}
379
478
  ontreekeydown={handleTreeKeydown}
479
+ onrowlongpress={handleRowLongpress}
480
+ oncheckboxselect={(index, event) => commitSelection(index, event, true)}
481
+ onrowhover={(index, event) => { const row = flattenedRows[index]; if (row) onrowhover?.(row.node, treeDataSnapshot, index, event); }}
482
+ onrowhoverend={(index, event) => { const row = flattenedRows[index]; if (row) onrowhoverend?.(row.node, treeDataSnapshot, index, event); }}
483
+ {longPressDelay}
484
+ {longPressMoveTolerance}
380
485
  />
381
486
 
382
487
  <VTreeContextMenu
@@ -0,0 +1,23 @@
1
+ import type { VTreeRecord } from './types.js';
2
+ export interface NodeIndex<T extends VTreeRecord = VTreeRecord> {
3
+ byId: Map<string, T>;
4
+ parentById: Map<string, string | null>;
5
+ }
6
+ export declare function mergeClasses(base: string, override?: string): string;
7
+ export declare function buildNodeIndex<T extends VTreeRecord>(roots: T[], getId: (node: T) => string, getChildren: (node: T) => T[]): NodeIndex<T>;
8
+ export declare function findAncestorIds(parentById: Map<string, string | null>, targetId: string): string[];
9
+ export declare function resolveRowHeight<T extends VTreeRecord>(rowHeight: number | ((node: T, level: number, index: number) => number), node: T | undefined, level: number, index: number, fallback?: number): number;
10
+ export interface MultiSelectClickInput {
11
+ currentIds: string[];
12
+ clickedId: string;
13
+ clickedIndex: number;
14
+ anchorIndex: number | null;
15
+ orderedRowIds: string[];
16
+ ctrlOrMeta?: boolean;
17
+ shift?: boolean;
18
+ }
19
+ export declare function computeMultiSelectClick(input: MultiSelectClickInput): {
20
+ ids: string[];
21
+ nextAnchorIndex: number;
22
+ };
23
+ export declare function isImageLikeValue(value: unknown): boolean;
@@ -0,0 +1,73 @@
1
+ export function mergeClasses(base, override) {
2
+ return [base, override].filter(Boolean).join(' ');
3
+ }
4
+ export function buildNodeIndex(roots, getId, getChildren) {
5
+ const byId = new Map();
6
+ const parentById = new Map();
7
+ const visiting = new Set();
8
+ const walk = (nodes, parentId) => {
9
+ for (const node of nodes) {
10
+ if (!node || visiting.has(node))
11
+ continue;
12
+ const id = getId(node);
13
+ if (!id || byId.has(id))
14
+ continue;
15
+ byId.set(id, node);
16
+ parentById.set(id, parentId);
17
+ visiting.add(node);
18
+ walk(getChildren(node), id);
19
+ visiting.delete(node);
20
+ }
21
+ };
22
+ walk(roots, null);
23
+ return { byId, parentById };
24
+ }
25
+ export function findAncestorIds(parentById, targetId) {
26
+ const visited = new Set();
27
+ const ancestors = [];
28
+ let current = targetId;
29
+ while (current && !visited.has(current)) {
30
+ visited.add(current);
31
+ const parent = parentById.get(current);
32
+ if (!parent)
33
+ break;
34
+ ancestors.push(parent);
35
+ current = parent;
36
+ }
37
+ return ancestors.reverse();
38
+ }
39
+ export function resolveRowHeight(rowHeight, node, level, index, fallback = 36) {
40
+ const value = typeof rowHeight === 'function' ? (node ? rowHeight(node, level, index) : fallback) : rowHeight;
41
+ return Number.isFinite(value) && value > 0 ? value : fallback;
42
+ }
43
+ export function computeMultiSelectClick(input) {
44
+ const current = new Set(input.currentIds);
45
+ let ids;
46
+ const anchor = input.anchorIndex ?? input.clickedIndex;
47
+ if (input.shift) {
48
+ const start = Math.max(0, Math.min(anchor, input.clickedIndex));
49
+ const end = Math.min(input.orderedRowIds.length - 1, Math.max(anchor, input.clickedIndex));
50
+ ids = input.ctrlOrMeta ? new Set(current) : new Set();
51
+ for (const id of input.orderedRowIds.slice(start, end + 1))
52
+ ids.add(id);
53
+ }
54
+ else if (input.ctrlOrMeta) {
55
+ ids = new Set(current);
56
+ if (ids.has(input.clickedId))
57
+ ids.delete(input.clickedId);
58
+ else
59
+ ids.add(input.clickedId);
60
+ }
61
+ else {
62
+ ids = new Set([input.clickedId]);
63
+ }
64
+ return {
65
+ ids: input.orderedRowIds.filter((id) => ids.has(id)),
66
+ nextAnchorIndex: input.clickedIndex
67
+ };
68
+ }
69
+ export function isImageLikeValue(value) {
70
+ if (typeof value !== 'string')
71
+ return false;
72
+ return /^(https?:\/\/|data:image\/|\/|\.\/|\.\.\/).+\.(png|jpe?g|gif|svg|webp)(\?.*)?$/i.test(value) || /^data:image\//i.test(value);
73
+ }
@@ -0,0 +1,20 @@
1
+ <script lang="ts">import VTree from "./VTree.svelte";
2
+ const data = [{
3
+ id: "root",
4
+ label: "Root",
5
+ children: [{
6
+ id: "deep",
7
+ label: "Deep child",
8
+ children: [{
9
+ id: "leaf",
10
+ label: "Leaf"
11
+ }]
12
+ }]
13
+ }];
14
+ let selected = null;
15
+ </script>
16
+
17
+ <div class="space-y-2">
18
+ <button class="btn preset-filled-primary-500" type="button" onclick={() => selected = data[0].children[0].children[0]}>Select leaf</button>
19
+ <VTree {data} {selected} onSelectedChange={(node) => selected = node} searchable={false} />
20
+ </div>
@@ -0,0 +1,18 @@
1
+ interface $$__sveltets_2_IsomorphicComponent<Props extends Record<string, any> = any, Events extends Record<string, any> = any, Slots extends Record<string, any> = any, Exports = {}, Bindings = string> {
2
+ new (options: import('svelte').ComponentConstructorOptions<Props>): import('svelte').SvelteComponent<Props, Events, Slots> & {
3
+ $$bindings?: Bindings;
4
+ } & Exports;
5
+ (internal: unknown, props: {
6
+ $$events?: Events;
7
+ $$slots?: Slots;
8
+ }): Exports & {
9
+ $set?: any;
10
+ $on?: any;
11
+ };
12
+ z_$$bindings?: Bindings;
13
+ }
14
+ declare const VTreeControlledSelectionDemo: $$__sveltets_2_IsomorphicComponent<Record<string, never>, {
15
+ [evt: string]: CustomEvent<any>;
16
+ }, {}, {}, string>;
17
+ type VTreeControlledSelectionDemo = InstanceType<typeof VTreeControlledSelectionDemo>;
18
+ export default VTreeControlledSelectionDemo;
@@ -0,0 +1,23 @@
1
+ <script lang="ts">// @ts-nocheck
2
+ import VTree from "./VTree.svelte";
3
+ const data = [{
4
+ id: "folder",
5
+ label: "Folder",
6
+ icon: "📁",
7
+ children: [{
8
+ id: "doc",
9
+ label: "Document",
10
+ icon: "📄"
11
+ }]
12
+ }, {
13
+ id: "settings",
14
+ label: "Settings",
15
+ icon: "⚙️"
16
+ }];
17
+ </script>
18
+
19
+ {#snippet customIcon({ node })}
20
+ <span aria-hidden="true">{node.id === 'folder' ? '🗂️' : '✨'}</span>
21
+ {/snippet}
22
+
23
+ <VTree {data} iconField="icon" icon={customIcon} searchable={false} />