@warkypublic/svelix 0.1.46 → 0.2.1

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 (129) hide show
  1. package/README.md +93 -33
  2. package/dist/components/BetterMenu/BetterMenu.svelte +21 -37
  3. package/dist/components/BetterMenu/BetterMenuAsyncButton.svelte +21 -31
  4. package/dist/components/BetterMenu/BetterMenuPreview.svelte +27 -29
  5. package/dist/components/BetterMenu/MenuRenderer.svelte +40 -51
  6. package/dist/components/Boxer/Boxer.svelte +492 -602
  7. package/dist/components/Boxer/BoxerTarget.svelte +6 -46
  8. package/dist/components/Boxer/BoxerTarget.svelte.d.ts +1 -0
  9. package/dist/components/Boxer/types.d.ts +0 -1
  10. package/dist/components/Button.svelte +19 -43
  11. package/dist/components/ButtonPreview.svelte +2 -11
  12. package/dist/components/CardGrid/CardGrid.svelte +489 -614
  13. package/dist/components/CardGrid/CardGrid.svelte.d.ts +4 -0
  14. package/dist/components/CardGrid/CardGridFilterPanel.svelte +111 -90
  15. package/dist/components/CardGrid/DefaultCard.svelte +86 -97
  16. package/dist/components/CardGrid/DefaultCard.svelte.d.ts +1 -0
  17. package/dist/components/CardGrid/ImageCardStory.svelte +87 -36
  18. package/dist/components/CardGrid/cardGrid.d.ts +24 -0
  19. package/dist/components/CardGrid/cardGrid.js +20 -0
  20. package/dist/components/ContentEditor/CollaboraInsertTextDemo.svelte +5 -10
  21. package/dist/components/ContentEditor/ContentEditor.svelte +24 -50
  22. package/dist/components/ContentEditor/subcomponents/AudioPlayer.svelte +17 -17
  23. package/dist/components/ContentEditor/subcomponents/CollaboraEditor.svelte +244 -309
  24. package/dist/components/ContentEditor/subcomponents/EmailViewer.svelte +2 -3
  25. package/dist/components/ContentEditor/subcomponents/ImageViewer.svelte +14 -15
  26. package/dist/components/ContentEditor/subcomponents/MarkdownViewer.svelte +74 -90
  27. package/dist/components/ContentEditor/subcomponents/MonacoEditor.svelte +75 -103
  28. package/dist/components/ContentEditor/subcomponents/Office365Editor.svelte +234 -297
  29. package/dist/components/ContentEditor/subcomponents/PdfViewer.svelte +14 -20
  30. package/dist/components/ContentEditor/subcomponents/TextEditor.svelte +122 -150
  31. package/dist/components/ContentEditor/subcomponents/UnknownFile.svelte +105 -126
  32. package/dist/components/ContentEditor/subcomponents/VideoPlayer.svelte +17 -17
  33. package/dist/components/ContentEditor/subcomponents/ZipViewer.svelte +2 -3
  34. package/dist/components/ErrorBoundary/ErrorBoundary.svelte +41 -58
  35. package/dist/components/ErrorBoundary/ErrorBoundaryPreview.svelte +8 -16
  36. package/dist/components/Former/Former.svelte +306 -424
  37. package/dist/components/Former/FormerButtonArea.svelte +17 -68
  38. package/dist/components/Former/FormerDrawer.svelte +16 -80
  39. package/dist/components/Former/FormerDrawerPreview.svelte +99 -89
  40. package/dist/components/Former/FormerModal.svelte +16 -74
  41. package/dist/components/Former/FormerModalPreview.svelte +99 -89
  42. package/dist/components/Former/FormerPreview.svelte +76 -78
  43. package/dist/components/Former/FormerRestApiPreview.svelte +55 -48
  44. package/dist/components/Former/formerOverlayHelpers.svelte.d.ts +11 -0
  45. package/dist/components/Former/formerOverlayHelpers.svelte.js +38 -0
  46. package/dist/components/Former/index.d.ts +1 -0
  47. package/dist/components/Former/index.js +1 -0
  48. package/dist/components/FormerControllers/ButtonCtrl.svelte +13 -49
  49. package/dist/components/FormerControllers/CtrlFieldShell.svelte +15 -0
  50. package/dist/components/FormerControllers/CtrlFieldShell.svelte.d.ts +12 -0
  51. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrl.svelte +285 -457
  52. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrlCalendar.svelte +95 -136
  53. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrlPickerPanel.svelte +3 -62
  54. package/dist/components/FormerControllers/DateTimeCtrl/DateTimeCtrlTimeFields.svelte +143 -217
  55. package/dist/components/FormerControllers/IconButtonCtrl.svelte +13 -47
  56. package/dist/components/FormerControllers/InlineWrapper.svelte +12 -41
  57. package/dist/components/FormerControllers/InlineWrapperPreview.svelte +4 -26
  58. package/dist/components/FormerControllers/NativeSelectCtrl.svelte +7 -39
  59. package/dist/components/FormerControllers/NumberInputCtrl.svelte +21 -43
  60. package/dist/components/FormerControllers/PasswordInputCtrl.svelte +8 -33
  61. package/dist/components/FormerControllers/SwitchCtrl.svelte +4 -20
  62. package/dist/components/FormerControllers/TextAreaCtrl.svelte +7 -34
  63. package/dist/components/FormerControllers/TextInputCtrl.svelte +7 -34
  64. package/dist/components/FormerControllers/ctrlLoading.svelte.d.ts +8 -0
  65. package/dist/components/FormerControllers/ctrlLoading.svelte.js +43 -0
  66. package/dist/components/FormerControllers/index.d.ts +2 -0
  67. package/dist/components/FormerControllers/index.js +2 -0
  68. package/dist/components/FormerControllers/utils.d.ts +1 -0
  69. package/dist/components/FormerControllers/utils.js +3 -0
  70. package/dist/components/GlobalStateStore/GlobalStateStoreProvider.svelte +73 -120
  71. package/dist/components/Gridler/components/Gridler.svelte +344 -442
  72. package/dist/components/Gridler/components/GridlerCanvas.svelte +720 -1115
  73. package/dist/components/Gridler/components/GridlerEditor.svelte +2 -15
  74. package/dist/components/Gridler/components/GridlerFull.svelte +521 -841
  75. package/dist/components/Gridler/components/GridlerFull.svelte.d.ts +1 -1
  76. package/dist/components/Gridler/components/GridlerFullWithFormerPreview.svelte +194 -147
  77. package/dist/components/Gridler/components/GridlerSearch.svelte +7 -16
  78. package/dist/components/Gridler/components/GridlerSearchToggle.svelte +7 -19
  79. package/dist/components/Gridler/types.d.ts +1 -1
  80. package/dist/components/Gridler/utils/serverPaging.d.ts +1 -0
  81. package/dist/components/Gridler/utils/serverPaging.js +7 -0
  82. package/dist/components/Icons/IconAdd.svelte +1 -3
  83. package/dist/components/Icons/IconAlertCircle.svelte +1 -3
  84. package/dist/components/Icons/IconAlertTriangle.svelte +1 -3
  85. package/dist/components/Icons/IconCamera.svelte +1 -3
  86. package/dist/components/Icons/IconClose.svelte +1 -3
  87. package/dist/components/Icons/IconEdit.svelte +1 -3
  88. package/dist/components/Icons/IconFilter.svelte +1 -3
  89. package/dist/components/Icons/IconSearch.svelte +1 -3
  90. package/dist/components/Icons/IconSort.svelte +1 -3
  91. package/dist/components/Icons/IconTrash.svelte +1 -3
  92. package/dist/components/Portal/Portal.svelte +14 -38
  93. package/dist/components/Screenshot/Screenshot.svelte +7 -13
  94. package/dist/components/Svark/Svark.svelte +680 -852
  95. package/dist/components/Svark/SvarkContextMenu.svelte +44 -63
  96. package/dist/components/Svark/SvarkInfiniteLoader.svelte +2 -6
  97. package/dist/components/Svark/SvarkPager.svelte +2 -28
  98. package/dist/components/Svark/SvarkSelectionDemo.svelte +4 -8
  99. package/dist/components/Svark/SvarkTopBar.svelte +9 -74
  100. package/dist/components/SvarkGrid/SvarkGrid.svelte +877 -1123
  101. package/dist/components/SvarkGrid/components/SvarkColumnFilterForm.svelte +45 -58
  102. package/dist/components/SvarkGrid/components/SvarkContextMenu.svelte +44 -63
  103. package/dist/components/SvarkGrid/components/SvarkHeaderFilterCell.svelte +41 -75
  104. package/dist/components/SvarkGrid/components/SvarkInfiniteLoader.svelte +2 -6
  105. package/dist/components/SvarkGrid/components/SvarkPager.svelte +2 -28
  106. package/dist/components/SvarkGrid/components/SvarkSearchPopover.svelte +8 -16
  107. package/dist/components/SvarkGrid/components/SvarkTopBar.svelte +6 -62
  108. package/dist/components/SvarkGrid/internal/SvarkGridView.svelte +345 -507
  109. package/dist/components/VTree/VTree.svelte +454 -431
  110. package/dist/components/VTree/VTree.utils.d.ts +23 -0
  111. package/dist/components/VTree/VTree.utils.js +73 -0
  112. package/dist/components/VTree/VTreeContextMenu.svelte +21 -33
  113. package/dist/components/VTree/VTreeControlledSelectionDemo.svelte +20 -0
  114. package/dist/components/VTree/VTreeControlledSelectionDemo.svelte.d.ts +18 -0
  115. package/dist/components/VTree/VTreeCustomizationDemo.svelte +23 -0
  116. package/dist/components/VTree/VTreeCustomizationDemo.svelte.d.ts +18 -0
  117. package/dist/components/VTree/VTreeEventsDemo.svelte +48 -58
  118. package/dist/components/VTree/VTreeInteractionDemo.svelte +29 -0
  119. package/dist/components/VTree/VTreeInteractionDemo.svelte.d.ts +18 -0
  120. package/dist/components/VTree/VTreeMultiSelectDemo.svelte +27 -0
  121. package/dist/components/VTree/VTreeMultiSelectDemo.svelte.d.ts +18 -0
  122. package/dist/components/VTree/VTreeRow.svelte +77 -115
  123. package/dist/components/VTree/VTreeRow.svelte.d.ts +34 -18
  124. package/dist/components/VTree/VTreeSearch.svelte +6 -13
  125. package/dist/components/VTree/VTreeVirtualViewport.svelte +54 -143
  126. package/dist/components/VTree/VTreeVirtualViewport.svelte.d.ts +35 -18
  127. package/dist/components/VTree/types.d.ts +39 -1
  128. package/llm/COMPONENT_GUIDE.md +25 -0
  129. package/package.json +51 -50
@@ -1,434 +1,441 @@
1
- <script lang="ts">
2
- import { onMount } from 'svelte';
3
- import VTreeSearch from './VTreeSearch.svelte';
4
- import VTreeVirtualViewport from './VTreeVirtualViewport.svelte';
5
- import VTreeContextMenu from './VTreeContextMenu.svelte';
6
- import type { VTreeFlatRow } from './VTree.models.js';
7
- import type { VTreeContextMenuItem, VTreeProps, VTreeRecord } from './types.js';
8
-
9
- export type Props<T extends VTreeRecord = VTreeRecord> = VTreeProps<T>;
10
-
11
- const {
12
- data = [],
13
- adapter,
14
- idField = 'id',
15
- labelField = 'label',
16
- descriptionField = 'description',
17
- childrenField = 'children',
18
- hasChildrenField = 'hasChildren',
19
- height = 420,
20
- rowHeight = 36,
21
- indent = 18,
22
- overscan = 8,
23
- searchable = true,
24
- searchPlaceholder = 'Search tree...',
25
- searchValue = '',
26
- serverSideSearch = false,
27
- autoExpandSearchResults = true,
28
- loadOnMount = true,
29
- emptyMessage = 'Nothing here...',
30
- loadingMessage = 'Loading...',
31
- leftSection,
32
- rightSection,
33
- contextMenuItems,
34
- onselect,
35
- onclick,
36
- ondoubleclick,
37
- oncontext,
38
- ontoggle,
39
- onsearch,
40
- onloaderror
41
- }: Props = $props();
42
-
43
- let viewportRef = $state<{ scrollToIndex: (index: number) => void } | undefined>(undefined);
44
- let focusedIndex = $state(0);
45
- let selectedId = $state<string | null>(null);
46
- let searchQuery = $state('');
47
- let loadingRoot = $state(false);
48
-
49
- let rootNodes = $state<VTreeRecord[]>([]);
50
- let searchNodes = $state<VTreeRecord[]>([]);
51
- let childrenByParent = $state<Record<string, VTreeRecord[]>>({});
52
- let expandedById = $state<Record<string, boolean>>({});
53
- let loadingById = $state<Record<string, boolean>>({});
54
- let loadedById = $state<Record<string, boolean>>({});
55
- let errorsById = $state<Record<string, string>>({});
56
-
57
- let lastSearchHandle: ReturnType<typeof setTimeout> | undefined;
58
- let searchRunId = 0;
59
- let contextOpen = $state(false);
60
- let contextX = $state(0);
61
- let contextY = $state(0);
62
- let contextRow = $state<VTreeRecord | null>(null);
63
- let contextItems = $state<VTreeContextMenuItem[]>([]);
64
-
65
- function getNodeId(node: VTreeRecord): string {
66
- const value = node?.[idField];
67
- return String(value ?? '');
68
- }
69
-
70
- function getNodeLabel(node: VTreeRecord): string {
71
- return String(node?.[labelField] ?? '');
72
- }
73
-
74
- function getNodeDescription(node: VTreeRecord): string {
75
- const value = node?.[descriptionField];
76
- return value === undefined || value === null ? '' : String(value);
77
- }
78
-
79
- function getNodeChildren(node: VTreeRecord): VTreeRecord[] {
80
- const nodeId = getNodeId(node);
81
- const remoteChildren = childrenByParent[nodeId];
82
- if (remoteChildren) return remoteChildren;
83
-
84
- const localChildren = node?.[childrenField];
85
- return Array.isArray(localChildren) ? (localChildren as VTreeRecord[]) : [];
86
- }
87
-
88
- function nodeHasChildren(node: VTreeRecord): boolean {
89
- const children = getNodeChildren(node);
90
- if (children.length > 0) return true;
91
- return typeof node?.[hasChildrenField] === 'boolean'
92
- ? Boolean(node[hasChildrenField])
93
- : false;
94
- }
95
-
96
- function normalizeRows(rows: VTreeRecord[]): VTreeRecord[] {
97
- return Array.isArray(rows) ? rows : [];
98
- }
99
-
100
- async function loadRoot() {
101
- if (!adapter) {
102
- rootNodes = normalizeRows(data ?? []);
103
- return;
104
- }
105
-
106
- loadingRoot = true;
107
- try {
108
- const rows = await adapter.readRoot();
109
- rootNodes = normalizeRows(rows);
110
- searchNodes = [];
111
- errorsById = {};
112
- } catch (error) {
113
- onloaderror?.(error);
114
- } finally {
115
- loadingRoot = false;
116
- }
117
- }
118
-
119
- async function loadChildren(node: VTreeRecord) {
120
- if (!adapter) return;
121
-
122
- const nodeId = getNodeId(node);
123
- if (!nodeId || loadingById[nodeId] || loadedById[nodeId]) return;
124
-
125
- loadingById = { ...loadingById, [nodeId]: true };
126
- errorsById = { ...errorsById, [nodeId]: '' };
127
-
128
- try {
129
- const rows = await adapter.readChildren(node);
130
- childrenByParent = { ...childrenByParent, [nodeId]: normalizeRows(rows) };
131
- loadedById = { ...loadedById, [nodeId]: true };
132
- } catch (error) {
133
- const message = error instanceof Error ? error.message : 'Unable to load node';
134
- errorsById = { ...errorsById, [nodeId]: message };
135
- onloaderror?.(error, node);
136
- } finally {
137
- loadingById = { ...loadingById, [nodeId]: false };
138
- }
139
- }
140
-
141
- function matchesLocalSearch(node: VTreeRecord, query: string): boolean {
142
- const q = query.toLowerCase();
143
- return (
144
- getNodeLabel(node).toLowerCase().includes(q) ||
145
- getNodeDescription(node).toLowerCase().includes(q)
146
- );
147
- }
148
-
149
- function toggleNode(node: VTreeRecord) {
150
- const nodeId = getNodeId(node);
151
- if (!nodeId) return;
152
-
153
- const currentlyExpanded = Boolean(expandedById[nodeId]);
154
- expandedById = { ...expandedById, [nodeId]: !currentlyExpanded };
155
- ontoggle?.(node, !currentlyExpanded);
156
-
157
- if (!currentlyExpanded && adapter && nodeHasChildren(node)) {
158
- void loadChildren(node);
159
- }
160
- }
161
-
162
- const displayRoots = $derived.by(() => {
163
- if (adapter && serverSideSearch && searchQuery.trim().length > 0) return searchNodes;
164
- return rootNodes;
165
- });
166
-
167
- const flattenedRows = $derived.by(() => {
168
- const rows: VTreeFlatRow[] = [];
169
- const activeSearch = searchQuery.trim().toLowerCase();
170
- const useLocalSearch = activeSearch.length > 0 && !(adapter && serverSideSearch);
171
-
172
- const walk = (
173
- nodes: VTreeRecord[],
174
- level: number,
175
- parentId: string | null
176
- ): { rows: VTreeFlatRow[]; matches: boolean } => {
177
- const walkRows: VTreeFlatRow[] = [];
178
- let hasAnyMatch = false;
179
-
180
- for (const node of nodes) {
181
- const id = getNodeId(node);
182
- const children = getNodeChildren(node);
183
- const childResult = walk(children, level + 1, id);
184
- const selfMatch = useLocalSearch ? matchesLocalSearch(node, activeSearch) : false;
185
- const childHasMatch = childResult.matches;
186
- const includeNode = !useLocalSearch || selfMatch || childHasMatch;
187
-
188
- if (!includeNode) continue;
189
-
190
- hasAnyMatch = true;
191
- const expanded = Boolean(expandedById[id]);
192
- const forceExpanded = useLocalSearch && autoExpandSearchResults && childHasMatch;
193
- walkRows.push({
194
- id,
195
- node,
196
- level,
197
- parentId,
198
- expanded,
199
- hasChildren: nodeHasChildren(node),
200
- loading: Boolean(loadingById[id]),
201
- error: errorsById[id] || undefined,
202
- matched: selfMatch
203
- });
204
-
205
- if ((expanded || forceExpanded) && childResult.rows.length > 0) {
206
- walkRows.push(...childResult.rows);
207
- }
208
- }
209
-
210
- return { rows: walkRows, matches: hasAnyMatch };
211
- };
212
-
213
- for (const root of displayRoots) {
214
- rows.push(...walk([root], 1, null).rows);
215
- }
216
- return rows;
217
- });
218
-
219
- const treeDataSnapshot = $derived.by(() => {
220
- const buildTree = (nodes: VTreeRecord[]): VTreeRecord[] =>
221
- nodes.map((node) => {
222
- const children = getNodeChildren(node);
223
- if (children.length === 0) {
224
- return node;
225
- }
226
- return {
227
- ...node,
228
- [childrenField]: buildTree(children)
229
- };
230
- });
231
-
232
- return buildTree(displayRoots);
233
- });
234
-
235
- function selectRow(index: number) {
236
- const row = flattenedRows[index];
237
- if (!row) return;
238
-
239
- focusedIndex = index;
240
- selectedId = row.id;
241
- onselect?.(row.node);
242
- viewportRef?.scrollToIndex(index);
243
- }
244
-
245
- function moveFocus(delta: number) {
246
- if (flattenedRows.length === 0) return;
247
- const next = Math.min(flattenedRows.length - 1, Math.max(0, focusedIndex + delta));
248
- selectRow(next);
249
- }
250
-
251
- function handleTreeKeydown(event: KeyboardEvent) {
252
- if (flattenedRows.length === 0) return;
253
-
254
- const current = flattenedRows[focusedIndex];
255
- if (!current) {
256
- selectRow(0);
257
- return;
258
- }
259
-
260
- if (event.key === 'ArrowDown') {
261
- event.preventDefault();
262
- moveFocus(1);
263
- return;
264
- }
265
- if (event.key === 'ArrowUp') {
266
- event.preventDefault();
267
- moveFocus(-1);
268
- return;
269
- }
270
- if (event.key === 'PageDown') {
271
- event.preventDefault();
272
- moveFocus(10);
273
- return;
274
- }
275
- if (event.key === 'PageUp') {
276
- event.preventDefault();
277
- moveFocus(-10);
278
- return;
279
- }
280
- if (event.key === 'Home') {
281
- event.preventDefault();
282
- selectRow(0);
283
- return;
284
- }
285
- if (event.key === 'End') {
286
- event.preventDefault();
287
- selectRow(flattenedRows.length - 1);
288
- return;
289
- }
290
- if (event.key === 'ArrowRight') {
291
- event.preventDefault();
292
- if (current.hasChildren && !current.expanded) {
293
- toggleNode(current.node);
294
- } else {
295
- moveFocus(1);
296
- }
297
- return;
298
- }
299
- if (event.key === 'ArrowLeft') {
300
- event.preventDefault();
301
- if (current.expanded) {
302
- toggleNode(current.node);
303
- } else if (current.parentId) {
304
- const parentIndex = flattenedRows.findIndex((row) => row.id === current.parentId);
305
- if (parentIndex >= 0) selectRow(parentIndex);
306
- }
307
- return;
308
- }
309
- if (event.key === ' ' || event.key === 'Enter') {
310
- event.preventDefault();
311
- selectRow(focusedIndex);
312
- if (current.hasChildren) {
313
- toggleNode(current.node);
314
- }
315
- }
316
- }
317
-
318
- async function runServerSearch(query: string) {
319
- if (!adapter || !serverSideSearch) return;
320
- const runId = ++searchRunId;
321
-
322
- if (!query.trim()) {
323
- searchNodes = [];
324
- return;
325
- }
326
-
327
- try {
328
- const rows = await adapter.search(query.trim());
329
- if (runId !== searchRunId) return;
330
- searchNodes = normalizeRows(rows);
331
- focusedIndex = 0;
332
- } catch (error) {
333
- if (runId !== searchRunId) return;
334
- onloaderror?.(error);
335
- }
336
- }
337
-
338
- function handleSearchInput(value: string) {
339
- searchQuery = value;
340
- onsearch?.(value);
341
- }
342
-
343
- function handleRowClick(index: number, event: MouseEvent) {
344
- const row = flattenedRows[index];
345
- if (!row) return;
346
- selectRow(index);
347
- onclick?.(row.node, treeDataSnapshot, event);
348
- }
349
-
350
- function handleRowDoubleClick(index: number, event: MouseEvent) {
351
- const row = flattenedRows[index];
352
- if (!row) return;
353
- selectRow(index);
354
- ondoubleclick?.(row.node, treeDataSnapshot, event);
355
- }
356
-
357
- function handleRowContext(index: number, event: MouseEvent) {
358
- const row = flattenedRows[index];
359
- if (!row) return;
360
- event.preventDefault();
361
- selectRow(index);
362
- oncontext?.(row.node, treeDataSnapshot, event);
363
-
364
- if (!contextMenuItems) return;
365
- const items =
366
- typeof contextMenuItems === 'function'
367
- ? contextMenuItems(row.node, treeDataSnapshot)
368
- : contextMenuItems;
369
- contextItems = items ?? [];
370
- contextRow = row.node;
371
- contextX = event.clientX;
372
- contextY = event.clientY;
373
- contextOpen = contextItems.length > 0;
374
- }
375
-
376
- function handleViewportContext(event: MouseEvent) {
377
- event.preventDefault();
378
- oncontext?.(null, treeDataSnapshot, event);
379
-
380
- if (!contextMenuItems) return;
381
- const items =
382
- typeof contextMenuItems === 'function'
383
- ? contextMenuItems(null, treeDataSnapshot)
384
- : contextMenuItems;
385
- contextItems = items ?? [];
386
- contextRow = null;
387
- contextX = event.clientX;
388
- contextY = event.clientY;
389
- contextOpen = contextItems.length > 0;
390
- }
391
-
392
- function handleContextMenuItem(item: VTreeContextMenuItem) {
393
- if (item.id !== '__close__' && contextRow) {
394
- item.onselect?.(contextRow, treeDataSnapshot);
395
- }
396
- contextOpen = false;
397
- }
398
-
399
- $effect(() => {
400
- if (!adapter) rootNodes = normalizeRows(data ?? []);
401
- });
402
-
403
- $effect(() => {
404
- if (!adapter || !serverSideSearch) return;
405
- clearTimeout(lastSearchHandle);
406
- lastSearchHandle = setTimeout(() => {
407
- void runServerSearch(searchQuery);
408
- }, 250);
409
- return () => clearTimeout(lastSearchHandle);
410
- });
411
-
412
- $effect(() => {
413
- if (focusedIndex > flattenedRows.length - 1) {
414
- focusedIndex = Math.max(flattenedRows.length - 1, 0);
415
- }
416
- if (!selectedId && flattenedRows.length > 0) {
417
- selectedId = flattenedRows[0].id;
418
- }
419
- });
420
-
421
- onMount(() => {
422
- searchQuery = searchValue ?? '';
423
-
424
- if (adapter && loadOnMount) {
425
- void loadRoot();
426
- } else {
427
- rootNodes = normalizeRows(data ?? []);
428
- }
429
-
430
- return () => clearTimeout(lastSearchHandle);
431
- });
1
+ <script lang="ts">import { onMount } from "svelte";
2
+ import VTreeSearch from "./VTreeSearch.svelte";
3
+ import VTreeVirtualViewport from "./VTreeVirtualViewport.svelte";
4
+ import VTreeContextMenu from "./VTreeContextMenu.svelte";
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();
7
+ let viewportRef = $state(undefined);
8
+ let focusedIndex = $state(0);
9
+ let selectedId = $state(null);
10
+ let searchQuery = $state("");
11
+ let loadingRoot = $state(false);
12
+ let rootNodes = $state([]);
13
+ let searchNodes = $state([]);
14
+ let childrenByParent = $state({});
15
+ let expandedById = $state({});
16
+ let loadingById = $state({});
17
+ let loadedById = $state({});
18
+ let errorsById = $state({});
19
+ let lastSearchHandle;
20
+ let searchRunId = 0;
21
+ let contextOpen = $state(false);
22
+ let contextX = $state(0);
23
+ let contextY = $state(0);
24
+ let contextRow = $state(null);
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
+ }
70
+ function getNodeId(node) {
71
+ const value = node?.[idField];
72
+ return String(value ?? "");
73
+ }
74
+ function getNodeLabel(node) {
75
+ return String(node?.[labelField] ?? "");
76
+ }
77
+ function getNodeDescription(node) {
78
+ const value = node?.[descriptionField];
79
+ return value === undefined || value === null ? "" : String(value);
80
+ }
81
+ function getNodeChildren(node) {
82
+ const nodeId = getNodeId(node);
83
+ const remoteChildren = childrenByParent[nodeId];
84
+ if (remoteChildren) return remoteChildren;
85
+ const localChildren = node?.[childrenField];
86
+ return Array.isArray(localChildren) ? localChildren : [];
87
+ }
88
+ function nodeHasChildren(node) {
89
+ const children = getNodeChildren(node);
90
+ if (children.length > 0) return true;
91
+ return typeof node?.[hasChildrenField] === "boolean" ? Boolean(node[hasChildrenField]) : false;
92
+ }
93
+ function normalizeRows(rows) {
94
+ return Array.isArray(rows) ? rows : [];
95
+ }
96
+ async function loadRoot() {
97
+ if (!adapter) {
98
+ rootNodes = normalizeRows(data ?? []);
99
+ return;
100
+ }
101
+ loadingRoot = true;
102
+ try {
103
+ const rows = await adapter.readRoot();
104
+ rootNodes = normalizeRows(rows);
105
+ searchNodes = [];
106
+ errorsById = {};
107
+ } catch (error) {
108
+ onloaderror?.(error);
109
+ } finally {
110
+ loadingRoot = false;
111
+ }
112
+ }
113
+ async function loadChildren(node) {
114
+ if (!adapter) return;
115
+ const nodeId = getNodeId(node);
116
+ if (!nodeId || loadingById[nodeId] || loadedById[nodeId]) return;
117
+ loadingById = {
118
+ ...loadingById,
119
+ [nodeId]: true
120
+ };
121
+ errorsById = {
122
+ ...errorsById,
123
+ [nodeId]: ""
124
+ };
125
+ try {
126
+ const rows = await adapter.readChildren(node);
127
+ childrenByParent = {
128
+ ...childrenByParent,
129
+ [nodeId]: normalizeRows(rows)
130
+ };
131
+ loadedById = {
132
+ ...loadedById,
133
+ [nodeId]: true
134
+ };
135
+ } catch (error) {
136
+ const message = error instanceof Error ? error.message : "Unable to load node";
137
+ errorsById = {
138
+ ...errorsById,
139
+ [nodeId]: message
140
+ };
141
+ onloaderror?.(error, node);
142
+ } finally {
143
+ loadingById = {
144
+ ...loadingById,
145
+ [nodeId]: false
146
+ };
147
+ }
148
+ }
149
+ function matchesLocalSearch(node, query) {
150
+ const q = query.toLowerCase();
151
+ return getNodeLabel(node).toLowerCase().includes(q) || getNodeDescription(node).toLowerCase().includes(q);
152
+ }
153
+ function toggleNode(node) {
154
+ const nodeId = getNodeId(node);
155
+ if (!nodeId) return;
156
+ const currentlyExpanded = Boolean(expandedById[nodeId]);
157
+ expandedById = {
158
+ ...expandedById,
159
+ [nodeId]: !currentlyExpanded
160
+ };
161
+ ontoggle?.(node, !currentlyExpanded);
162
+ if (!currentlyExpanded && adapter && nodeHasChildren(node)) {
163
+ void loadChildren(node);
164
+ }
165
+ }
166
+ const displayRoots = $derived.by(() => {
167
+ if (adapter && serverSideSearch && searchQuery.trim().length > 0) return searchNodes;
168
+ return rootNodes;
169
+ });
170
+ const nodeIndex = $derived.by(() => buildNodeIndex(displayRoots, getNodeId, getNodeChildren));
171
+ const flattenedRows = $derived.by(() => {
172
+ const rows = [];
173
+ const activeSearch = searchQuery.trim().toLowerCase();
174
+ const useLocalSearch = activeSearch.length > 0 && !(adapter && serverSideSearch);
175
+ const walk = (nodes, level, parentId) => {
176
+ const walkRows = [];
177
+ let hasAnyMatch = false;
178
+ for (const node of nodes) {
179
+ const id = getNodeId(node);
180
+ const children = getNodeChildren(node);
181
+ const childResult = walk(children, level + 1, id);
182
+ const selfMatch = useLocalSearch ? matchesLocalSearch(node, activeSearch) : false;
183
+ const childHasMatch = childResult.matches;
184
+ const includeNode = !useLocalSearch || selfMatch || childHasMatch;
185
+ if (!includeNode) continue;
186
+ hasAnyMatch = true;
187
+ const expanded = Boolean(expandedById[id]);
188
+ const forceExpanded = useLocalSearch && autoExpandSearchResults && childHasMatch;
189
+ walkRows.push({
190
+ id,
191
+ node,
192
+ level,
193
+ parentId,
194
+ expanded,
195
+ hasChildren: nodeHasChildren(node),
196
+ loading: Boolean(loadingById[id]),
197
+ error: errorsById[id] || undefined,
198
+ matched: selfMatch
199
+ });
200
+ if ((expanded || forceExpanded) && childResult.rows.length > 0) {
201
+ walkRows.push(...childResult.rows);
202
+ }
203
+ }
204
+ return {
205
+ rows: walkRows,
206
+ matches: hasAnyMatch
207
+ };
208
+ };
209
+ for (const root of displayRoots) {
210
+ rows.push(...walk([root], 1, null).rows);
211
+ }
212
+ return rows;
213
+ });
214
+ const treeDataSnapshot = $derived.by(() => {
215
+ const buildTree = (nodes) => nodes.map((node) => {
216
+ const children = getNodeChildren(node);
217
+ if (children.length === 0) {
218
+ return node;
219
+ }
220
+ return {
221
+ ...node,
222
+ [childrenField]: buildTree(children)
223
+ };
224
+ });
225
+ return buildTree(displayRoots);
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
+ });
252
+ function selectRow(index) {
253
+ const row = flattenedRows[index];
254
+ if (!row) return;
255
+ focusedIndex = index;
256
+ // Keep existing uncontrolled fallback behavior.
257
+ selectedId = row.id;
258
+ onselect?.(row.node);
259
+ viewportRef?.scrollToIndex(index);
260
+ }
261
+ function moveFocus(delta) {
262
+ if (flattenedRows.length === 0) return;
263
+ const next = Math.min(flattenedRows.length - 1, Math.max(0, focusedIndex + delta));
264
+ selectRow(next);
265
+ }
266
+ function handleTreeKeydown(event) {
267
+ if (flattenedRows.length === 0) return;
268
+ const current = flattenedRows[focusedIndex];
269
+ if (!current) {
270
+ selectRow(0);
271
+ return;
272
+ }
273
+ if (event.key === "ArrowDown") {
274
+ event.preventDefault();
275
+ moveFocus(1);
276
+ return;
277
+ }
278
+ if (event.key === "ArrowUp") {
279
+ event.preventDefault();
280
+ moveFocus(-1);
281
+ return;
282
+ }
283
+ if (event.key === "PageDown") {
284
+ event.preventDefault();
285
+ moveFocus(10);
286
+ return;
287
+ }
288
+ if (event.key === "PageUp") {
289
+ event.preventDefault();
290
+ moveFocus(-10);
291
+ return;
292
+ }
293
+ if (event.key === "Home") {
294
+ event.preventDefault();
295
+ selectRow(0);
296
+ return;
297
+ }
298
+ if (event.key === "End") {
299
+ event.preventDefault();
300
+ selectRow(flattenedRows.length - 1);
301
+ return;
302
+ }
303
+ if (event.key === "ArrowRight") {
304
+ event.preventDefault();
305
+ if (current.hasChildren && !current.expanded) {
306
+ toggleNode(current.node);
307
+ } else {
308
+ moveFocus(1);
309
+ }
310
+ return;
311
+ }
312
+ if (event.key === "ArrowLeft") {
313
+ event.preventDefault();
314
+ if (current.expanded) {
315
+ toggleNode(current.node);
316
+ } else if (current.parentId) {
317
+ const parentIndex = flattenedRows.findIndex((row) => row.id === current.parentId);
318
+ if (parentIndex >= 0) selectRow(parentIndex);
319
+ }
320
+ return;
321
+ }
322
+ if (event.key === "Enter") {
323
+ event.preventDefault();
324
+ selectRow(focusedIndex);
325
+ // Single-click selection via Enter.
326
+ commitSelection(focusedIndex);
327
+ if (current.hasChildren) {
328
+ toggleNode(current.node);
329
+ }
330
+ }
331
+ // Single-click selection via Space.
332
+ if (event.key === " ") {
333
+ event.preventDefault();
334
+ commitSelection(focusedIndex);
335
+ }
336
+ }
337
+ async function runServerSearch(query) {
338
+ if (!adapter || !serverSideSearch) return;
339
+ const runId = ++searchRunId;
340
+ if (!query.trim()) {
341
+ searchNodes = [];
342
+ return;
343
+ }
344
+ try {
345
+ const rows = await adapter.search(query.trim());
346
+ if (runId !== searchRunId) return;
347
+ searchNodes = normalizeRows(rows);
348
+ focusedIndex = 0;
349
+ } catch (error) {
350
+ if (runId !== searchRunId) return;
351
+ onloaderror?.(error);
352
+ }
353
+ }
354
+ function handleSearchInput(value) {
355
+ searchQuery = value;
356
+ onsearch?.(value);
357
+ }
358
+ function handleRowClick(index, event) {
359
+ const row = flattenedRows[index];
360
+ if (!row) return;
361
+ selectRow(index);
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);
371
+ }
372
+ function handleRowDoubleClick(index, event) {
373
+ const row = flattenedRows[index];
374
+ if (!row) return;
375
+ clearTimeout(singleClickTimer);
376
+ clickCount = 0;
377
+ selectRow(index);
378
+ ondoubleclick?.(row.node, treeDataSnapshot, event);
379
+ }
380
+ function handleRowContext(index, event) {
381
+ const row = flattenedRows[index];
382
+ if (!row) return;
383
+ event.preventDefault();
384
+ selectRow(index);
385
+ oncontext?.(row.node, treeDataSnapshot, event);
386
+ if (!contextMenuItems) return;
387
+ const items = typeof contextMenuItems === "function" ? contextMenuItems(row.node, treeDataSnapshot) : contextMenuItems;
388
+ contextItems = items ?? [];
389
+ contextRow = row.node;
390
+ contextX = event.clientX;
391
+ contextY = event.clientY;
392
+ contextOpen = contextItems.length > 0;
393
+ }
394
+ function handleViewportContext(event) {
395
+ event.preventDefault();
396
+ oncontext?.(null, treeDataSnapshot, event);
397
+ if (!contextMenuItems) return;
398
+ const items = typeof contextMenuItems === "function" ? contextMenuItems(null, treeDataSnapshot) : contextMenuItems;
399
+ contextItems = items ?? [];
400
+ contextRow = null;
401
+ contextX = event.clientX;
402
+ contextY = event.clientY;
403
+ contextOpen = contextItems.length > 0;
404
+ }
405
+ function handleContextMenuItem(item) {
406
+ if (item.id !== "__close__" && contextRow) {
407
+ item.onselect?.(contextRow, treeDataSnapshot);
408
+ }
409
+ contextOpen = false;
410
+ }
411
+ $effect(() => {
412
+ if (!adapter) rootNodes = normalizeRows(data ?? []);
413
+ });
414
+ $effect(() => {
415
+ if (!adapter || !serverSideSearch) return;
416
+ clearTimeout(lastSearchHandle);
417
+ lastSearchHandle = setTimeout(() => {
418
+ void runServerSearch(searchQuery);
419
+ }, 250);
420
+ return () => clearTimeout(lastSearchHandle);
421
+ });
422
+ $effect(() => {
423
+ if (focusedIndex > flattenedRows.length - 1) {
424
+ focusedIndex = Math.max(flattenedRows.length - 1, 0);
425
+ }
426
+ if (!selectedId && flattenedRows.length > 0) {
427
+ selectedId = flattenedRows[0].id;
428
+ }
429
+ });
430
+ onMount(() => {
431
+ searchQuery = searchValue ?? "";
432
+ if (adapter && loadOnMount) {
433
+ void loadRoot();
434
+ } else {
435
+ rootNodes = normalizeRows(data ?? []);
436
+ }
437
+ return () => clearTimeout(lastSearchHandle);
438
+ });
432
439
  </script>
433
440
 
434
441
  <div class="card space-y-2 border border-surface-300-700 bg-surface-100-900 p-2">
@@ -440,6 +447,7 @@
440
447
  bind:this={viewportRef}
441
448
  rows={flattenedRows}
442
449
  {rowHeight}
450
+ {autoMeasure}
443
451
  {height}
444
452
  {indent}
445
453
  {overscan}
@@ -453,12 +461,27 @@
453
461
  {treeDataSnapshot}
454
462
  {leftSection}
455
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}
456
473
  onselectrow={handleRowClick}
457
474
  onrowdoubleclick={handleRowDoubleClick}
458
475
  onrowcontext={handleRowContext}
459
476
  onviewportcontext={handleViewportContext}
460
477
  ontogglerow={toggleNode}
461
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}
462
485
  />
463
486
 
464
487
  <VTreeContextMenu