@svgrid/grid 2.1.22 → 2.2.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.
Files changed (295) hide show
  1. package/README.md +9 -2
  2. package/dist/DockNodeView.svelte +306 -0
  3. package/dist/DockNodeView.svelte.d.ts +7 -0
  4. package/dist/DockPopoutHost.svelte +17 -0
  5. package/dist/DockPopoutHost.svelte.d.ts +14 -0
  6. package/dist/FlexRender.svelte +96 -96
  7. package/dist/GridFooter.svelte +178 -178
  8. package/dist/GridMenus.svelte +83 -3
  9. package/dist/SvAutoComplete.svelte +5 -3
  10. package/dist/SvCarousel.svelte +141 -163
  11. package/dist/SvColorInput.svelte +187 -186
  12. package/dist/SvColorInput.svelte.d.ts +1 -1
  13. package/dist/SvComboBox.svelte +7 -3
  14. package/dist/SvCommand.svelte +28 -85
  15. package/dist/SvContextMenu.svelte +116 -116
  16. package/dist/SvCountryInput.svelte +1 -1
  17. package/dist/SvDateRangeInput.svelte +1 -1
  18. package/dist/SvDateTimePicker.svelte +9 -3
  19. package/dist/SvDateTimePicker.svelte.d.ts +3 -0
  20. package/dist/SvDockLayout.svelte +244 -0
  21. package/dist/SvDockLayout.svelte.d.ts +77 -0
  22. package/dist/SvDockManager.svelte +922 -0
  23. package/dist/SvDockManager.svelte.d.ts +106 -0
  24. package/dist/SvDrawer.svelte +250 -228
  25. package/dist/SvDrawer.svelte.d.ts +4 -0
  26. package/dist/SvDropDownList.svelte +85 -13
  27. package/dist/SvDropDownList.svelte.d.ts +3 -0
  28. package/dist/SvField.svelte +21 -4
  29. package/dist/SvField.svelte.d.ts +1 -0
  30. package/dist/SvForm.svelte +201 -91
  31. package/dist/SvForm.svelte.d.ts +15 -22
  32. package/dist/SvGrid.controller.svelte.d.ts +32 -1
  33. package/dist/SvGrid.controller.svelte.js +156 -27
  34. package/dist/SvGrid.css +193 -5
  35. package/dist/SvGrid.svelte +3131 -2786
  36. package/dist/SvGrid.types.d.ts +452 -1
  37. package/dist/SvGridBoard.svelte +2339 -2339
  38. package/dist/SvGridChart.svelte +1724 -1724
  39. package/dist/SvGridChartPanel.svelte +485 -485
  40. package/dist/SvGridDropdown.svelte +689 -682
  41. package/dist/SvGridSelect.svelte +1 -0
  42. package/dist/SvGroupCell.svelte +109 -109
  43. package/dist/SvListBox.svelte +36 -14
  44. package/dist/SvMaskedInput.svelte +4 -3
  45. package/dist/SvMaskedInput.svelte.d.ts +1 -1
  46. package/dist/SvMenu.svelte +124 -124
  47. package/dist/SvMenuList.svelte +117 -165
  48. package/dist/SvMenuList.svelte.d.ts +2 -13
  49. package/dist/SvModal.svelte +12 -29
  50. package/dist/SvMultiSelect.svelte +1 -0
  51. package/dist/SvNumberInput.svelte +131 -130
  52. package/dist/SvNumberInput.svelte.d.ts +1 -1
  53. package/dist/SvOtpInput.svelte +158 -158
  54. package/dist/SvPagination.svelte +18 -18
  55. package/dist/SvPasswordInput.svelte +3 -2
  56. package/dist/SvPasswordInput.svelte.d.ts +1 -1
  57. package/dist/SvPhoneInput.svelte +3 -2
  58. package/dist/SvPhoneInput.svelte.d.ts +1 -1
  59. package/dist/SvPopover.svelte +139 -139
  60. package/dist/SvRowGroupPanel.svelte +170 -170
  61. package/dist/SvScrollArea.svelte +61 -61
  62. package/dist/SvStepper.svelte +6 -9
  63. package/dist/SvTagsInput.svelte +3 -2
  64. package/dist/SvTagsInput.svelte.d.ts +1 -1
  65. package/dist/SvTooltip.svelte +127 -138
  66. package/dist/SvTour.svelte +204 -176
  67. package/dist/SvTree.svelte +377 -377
  68. package/dist/SvTreeSelect.svelte +230 -229
  69. package/dist/build-api.js +76 -54
  70. package/dist/cdn/svgrid.js +10913 -6450
  71. package/dist/cdn/svgrid.svelte-external.js +9628 -5481
  72. package/dist/chart-export.js +8 -8
  73. package/dist/columns.js +6 -0
  74. package/dist/conditional-formatting.d.ts +67 -3
  75. package/dist/conditional-formatting.js +124 -17
  76. package/dist/createCarousel.svelte.d.ts +72 -0
  77. package/dist/createCarousel.svelte.js +80 -0
  78. package/dist/createCommand.svelte.d.ts +62 -0
  79. package/dist/createCommand.svelte.js +109 -0
  80. package/dist/createForm.svelte.d.ts +62 -0
  81. package/dist/createForm.svelte.js +374 -0
  82. package/dist/createMenu.svelte.d.ts +88 -0
  83. package/dist/createMenu.svelte.js +153 -0
  84. package/dist/createOverlay.svelte.d.ts +38 -0
  85. package/dist/createOverlay.svelte.js +68 -0
  86. package/dist/createPagination.d.ts +74 -0
  87. package/dist/createPagination.js +71 -0
  88. package/dist/createPopoverSelect.svelte.js +6 -12
  89. package/dist/createStepper.d.ts +44 -0
  90. package/dist/createStepper.js +26 -0
  91. package/dist/createTooltip.svelte.d.ts +32 -0
  92. package/dist/createTooltip.svelte.js +61 -0
  93. package/dist/datetime/timezone.d.ts +36 -0
  94. package/dist/datetime/timezone.js +110 -0
  95. package/dist/dock-context.d.ts +52 -0
  96. package/dist/dock-context.js +1 -0
  97. package/dist/dock-manager-model.d.ts +136 -0
  98. package/dist/dock-manager-model.js +514 -0
  99. package/dist/dock-model.d.ts +105 -0
  100. package/dist/dock-model.js +288 -0
  101. package/dist/dock-popout.d.ts +30 -0
  102. package/dist/dock-popout.js +81 -0
  103. package/dist/editor-contract.d.ts +2 -0
  104. package/dist/filter-operators.d.ts +6 -0
  105. package/dist/filter-operators.js +35 -2
  106. package/dist/filtering/excel-filters.d.ts +18 -1
  107. package/dist/filtering/excel-filters.js +56 -0
  108. package/dist/form-field.d.ts +73 -0
  109. package/dist/form-field.js +15 -0
  110. package/dist/index.d.ts +25 -5
  111. package/dist/index.js +40 -4
  112. package/dist/list-nav.d.ts +18 -0
  113. package/dist/list-nav.js +30 -0
  114. package/dist/list-option.d.ts +2 -0
  115. package/dist/menu-item.d.ts +20 -0
  116. package/dist/menu-item.js +1 -0
  117. package/dist/menus.d.ts +5 -0
  118. package/dist/menus.js +68 -16
  119. package/dist/panel-resize.d.ts +25 -0
  120. package/dist/panel-resize.js +40 -0
  121. package/dist/popover.d.ts +29 -3
  122. package/dist/popover.js +22 -6
  123. package/dist/recurrence.d.ts +33 -7
  124. package/dist/recurrence.js +151 -13
  125. package/dist/row-drag.d.ts +8 -0
  126. package/dist/row-drag.js +11 -0
  127. package/dist/scheduler-ical.d.ts +30 -0
  128. package/dist/scheduler-ical.js +180 -0
  129. package/dist/scheduler-model.d.ts +269 -0
  130. package/dist/scheduler-model.js +602 -0
  131. package/dist/scheduler-view.svelte.d.ts +22 -0
  132. package/dist/scheduler-view.svelte.js +13 -0
  133. package/dist/spreadsheet.js +20 -12
  134. package/dist/ui-app.types.d.ts +3 -0
  135. package/package.json +14 -10
  136. package/src/DockNodeView.svelte +306 -0
  137. package/src/DockPopoutHost.svelte +17 -0
  138. package/src/FlexRender.svelte +96 -96
  139. package/src/GridFooter.svelte +178 -178
  140. package/src/GridMenus.svelte +83 -3
  141. package/src/SvAutoComplete.svelte +5 -3
  142. package/src/SvCarousel.svelte +141 -163
  143. package/src/SvCarousel.test.ts +68 -0
  144. package/src/SvColorInput.svelte +187 -186
  145. package/src/SvComboBox.svelte +7 -3
  146. package/src/SvCommand.svelte +28 -85
  147. package/src/SvCommand.test.ts +78 -0
  148. package/src/SvContextMenu.svelte +116 -116
  149. package/src/SvCountryInput.svelte +1 -1
  150. package/src/SvDateRangeInput.svelte +1 -1
  151. package/src/SvDateTimePicker.svelte +9 -3
  152. package/src/SvDockLayout.svelte +244 -0
  153. package/src/SvDockManager.svelte +922 -0
  154. package/src/SvDrawer.svelte +250 -228
  155. package/src/SvDropDownList.svelte +85 -13
  156. package/src/SvField.svelte +21 -4
  157. package/src/SvForm.svelte +201 -91
  158. package/src/SvForm.test.ts +299 -0
  159. package/src/SvGrid.controller.svelte.ts +3158 -3034
  160. package/src/SvGrid.css +193 -5
  161. package/src/SvGrid.svelte +3131 -2786
  162. package/src/SvGrid.types.ts +1489 -1027
  163. package/src/SvGridBoard.svelte +2339 -2339
  164. package/src/SvGridChart.svelte +1724 -1724
  165. package/src/SvGridChartPanel.svelte +485 -485
  166. package/src/SvGridDropdown.svelte +689 -682
  167. package/src/SvGridSelect.svelte +1 -0
  168. package/src/SvGroupCell.svelte +109 -109
  169. package/src/SvListBox.svelte +36 -14
  170. package/src/SvMaskedInput.svelte +4 -3
  171. package/src/SvMenu.svelte +124 -124
  172. package/src/SvMenu.test.ts +97 -73
  173. package/src/SvMenuList.svelte +117 -165
  174. package/src/SvModal.svelte +12 -29
  175. package/src/SvModal.test.ts +82 -0
  176. package/src/SvMultiSelect.svelte +1 -0
  177. package/src/SvNumberInput.svelte +131 -130
  178. package/src/SvOtpInput.svelte +158 -158
  179. package/src/SvPagination.svelte +18 -18
  180. package/src/SvPasswordInput.svelte +3 -2
  181. package/src/SvPhoneInput.svelte +3 -2
  182. package/src/SvPopover.svelte +139 -139
  183. package/src/SvRowGroupPanel.svelte +170 -170
  184. package/src/SvScrollArea.svelte +61 -61
  185. package/src/SvStepper.svelte +6 -9
  186. package/src/SvTagsInput.svelte +3 -2
  187. package/src/SvTooltip.svelte +127 -138
  188. package/src/SvTooltip.test.ts +73 -0
  189. package/src/SvTour.svelte +204 -176
  190. package/src/SvTree.svelte +377 -377
  191. package/src/SvTreeSelect.svelte +230 -229
  192. package/src/a11y.contract.test.ts +49 -49
  193. package/src/a11y.test.ts +59 -59
  194. package/src/a11y.ts +61 -61
  195. package/src/build-api.ts +870 -847
  196. package/src/cell-formatting.ts +169 -169
  197. package/src/cell-render.ts +469 -469
  198. package/src/chart-export.ts +201 -201
  199. package/src/chart.ts +2296 -2296
  200. package/src/collaboration.test.ts +104 -104
  201. package/src/collaboration.ts +167 -167
  202. package/src/columns.ts +5 -0
  203. package/src/conditional-formatting.test.ts +104 -0
  204. package/src/conditional-formatting.ts +176 -19
  205. package/src/core.performance.test.ts +30 -30
  206. package/src/core.ts +1111 -1111
  207. package/src/createAutocomplete.svelte.ts +132 -132
  208. package/src/createCarousel.svelte.ts +104 -0
  209. package/src/createCombobox.svelte.ts +179 -179
  210. package/src/createCommand.svelte.ts +125 -0
  211. package/src/createCountryInput.svelte.ts +157 -157
  212. package/src/createDropdownList.svelte.ts +162 -162
  213. package/src/createForm.svelte.ts +333 -0
  214. package/src/createGrid.svelte.ts +42 -42
  215. package/src/createGrid.test.ts +10 -10
  216. package/src/createGridState.svelte.ts +17 -17
  217. package/src/createMenu.svelte.ts +202 -0
  218. package/src/createOverlay.svelte.ts +101 -0
  219. package/src/createPagination.test.ts +65 -0
  220. package/src/createPagination.ts +84 -0
  221. package/src/createPopoverSelect.svelte.ts +206 -210
  222. package/src/createStepper.test.ts +62 -0
  223. package/src/createStepper.ts +61 -0
  224. package/src/createTooltip.svelte.ts +86 -0
  225. package/src/createTree.svelte.ts +319 -319
  226. package/src/datetime/timezone.test.ts +69 -0
  227. package/src/datetime/timezone.ts +134 -0
  228. package/src/dock-context.ts +47 -0
  229. package/src/dock-manager-model.test.ts +275 -0
  230. package/src/dock-manager-model.ts +593 -0
  231. package/src/dock-model.test.ts +163 -0
  232. package/src/dock-model.ts +368 -0
  233. package/src/dock-popout.ts +94 -0
  234. package/src/editing.test.ts +859 -859
  235. package/src/editing.ts +675 -675
  236. package/src/editor-contract.ts +2 -0
  237. package/src/export-data-api.test.ts +126 -126
  238. package/src/export-format.test.ts +107 -107
  239. package/src/export-format.ts +598 -598
  240. package/src/filter-operators.test.ts +59 -0
  241. package/src/filter-operators.ts +35 -2
  242. package/src/filtering/excel-filters.test.ts +86 -1
  243. package/src/filtering/excel-filters.ts +65 -0
  244. package/src/flex-render.ts +3 -3
  245. package/src/form-field.ts +87 -0
  246. package/src/index.ts +623 -469
  247. package/src/keyboard.test.ts +59 -59
  248. package/src/keyboard.ts +97 -97
  249. package/src/list-nav.test.ts +49 -0
  250. package/src/list-nav.ts +29 -0
  251. package/src/list-option.ts +2 -0
  252. package/src/menu-item.ts +21 -0
  253. package/src/menus.test.ts +48 -0
  254. package/src/menus.ts +639 -582
  255. package/src/merge-objects.ts +48 -48
  256. package/src/overlays.test.ts +90 -90
  257. package/src/panel-resize.ts +52 -0
  258. package/src/popover.bounds.test.ts +93 -0
  259. package/src/popover.ts +51 -7
  260. package/src/recurrence.test.ts +88 -1
  261. package/src/recurrence.ts +166 -15
  262. package/src/render-component.ts +28 -28
  263. package/src/row-drag.ts +10 -0
  264. package/src/scheduler-ical.test.ts +68 -0
  265. package/src/scheduler-ical.ts +180 -0
  266. package/src/scheduler-model.test.ts +546 -0
  267. package/src/scheduler-model.ts +853 -0
  268. package/src/scheduler-view.svelte.ts +33 -0
  269. package/src/selection.test.ts +754 -754
  270. package/src/selection.ts +600 -600
  271. package/src/server-data-source.test.ts +289 -289
  272. package/src/server-data-source.ts +413 -413
  273. package/src/sparkline.test.ts +68 -68
  274. package/src/sparkline.ts +169 -169
  275. package/src/spreadsheet.test.ts +489 -489
  276. package/src/spreadsheet.ts +312 -304
  277. package/src/static-functions.ts +11 -11
  278. package/src/subscribe.ts +38 -38
  279. package/src/svgrid-wrapper.types.ts +501 -501
  280. package/src/svgrid.behavior.test.ts +706 -706
  281. package/src/svgrid.charting.test.ts +527 -527
  282. package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
  283. package/src/svgrid.features.test.ts +157 -157
  284. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  285. package/src/svgrid.wrapper.test.ts +40 -40
  286. package/src/test-setup.ts +62 -62
  287. package/src/themes/index.ts +159 -159
  288. package/src/ui-app.types.ts +3 -0
  289. package/src/virtual.test.ts +6 -4
  290. package/src/virtualization/column-virtualizer.test.ts +27 -27
  291. package/src/virtualization/column-virtualizer.ts +30 -30
  292. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  293. package/src/virtualization/types.ts +30 -30
  294. package/src/virtualization/virtualizer.test.ts +47 -47
  295. package/src/virtualization/virtualizer.ts +296 -296
@@ -1,377 +1,377 @@
1
- <script lang="ts" module>
2
- export type { TreeNode } from './createTree.svelte'
3
- </script>
4
-
5
- <script lang="ts">
6
- /**
7
- * SvTree - a WAI-ARIA tree view. Expand/collapse, single-select highlight,
8
- * cascading tri-state checkboxes, keyboard, and - for large or remote data -
9
- * fixed-row `virtual` windowing (scales to tens of thousands of nodes) and
10
- * `loadChildren` lazy-loading. Parity: Smart `smart-tree`.
11
- *
12
- * The behavior (flattening, cascade math, keyboard, ARIA) lives in the headless
13
- * `createTree` core; this component renders it, and owns the DOM concerns
14
- * (windowing, focus, and merging lazily-loaded children into the node tree).
15
- */
16
- import { tick, flushSync } from 'svelte'
17
- import { type EditorDir } from './editor-contract'
18
- import { virtualRange, scrollToIndex } from './virtual'
19
- import { createTree, sortTreeNodes, type TreeNode, type TreeDropPosition } from './createTree.svelte'
20
-
21
- type Props = {
22
- nodes: ReadonlyArray<TreeNode>
23
- /** Selected node id (single-select highlight). */
24
- selected?: string | null
25
- onSelect?: (id: string) => void
26
- /** Initially/controlled expanded ids. */
27
- expandedIds?: string[]
28
- onToggle?: (id: string, expanded: boolean) => void
29
- /** Show cascading checkboxes; `checked` is the set of checked ids. */
30
- checkable?: boolean
31
- checked?: string[]
32
- onCheck?: (ids: string[]) => void
33
- ariaLabel?: string
34
- /** Text direction (rtl mirrors indentation + flips Left/Right keys). */
35
- dir?: EditorDir
36
- /** Window rows (render only visible) - for large trees. Needs `height`. */
37
- virtual?: boolean
38
- /** Fixed row height in px (must match the CSS). Default 30. */
39
- rowHeight?: number
40
- /** Scroll-viewport height in px. Enables scrolling (required for `virtual`). */
41
- height?: number
42
- /** Load a lazy node's children on first expand (node has `lazy: true`). */
43
- loadChildren?: (node: TreeNode) => Promise<TreeNode[]>
44
- /** Filter query - show only matching nodes + ancestors, auto-expanded. */
45
- filter?: string
46
- /** Render a built-in search box that drives the filter. */
47
- searchable?: boolean
48
- searchPlaceholder?: string
49
- /** Sort siblings by label ('asc'/'desc') or a custom comparator. */
50
- sort?: 'asc' | 'desc' | ((a: TreeNode, b: TreeNode) => number)
51
- /** Allow inline rename (double-click or F2). Fires onRename. */
52
- editable?: boolean
53
- onRename?: (id: string, label: string) => void
54
- /** Allow drag-drop reordering; fires onMove(dragId, targetId, position).
55
- * Apply it with the exported `moveTreeNode(nodes, ...)` helper. */
56
- reorderable?: boolean
57
- onMove?: (dragId: string, targetId: string, position: TreeDropPosition) => void
58
- }
59
-
60
- let {
61
- nodes,
62
- selected = null,
63
- onSelect,
64
- expandedIds,
65
- onToggle,
66
- checkable = false,
67
- checked = [],
68
- onCheck,
69
- ariaLabel,
70
- dir,
71
- virtual = false,
72
- rowHeight = 30,
73
- height,
74
- loadChildren,
75
- filter,
76
- searchable = false,
77
- searchPlaceholder = 'Search...',
78
- sort,
79
- editable = false,
80
- onRename,
81
- reorderable = false,
82
- onMove,
83
- }: Props = $props()
84
-
85
- const resolvedDir = $derived(dir === 'ltr' || dir === 'rtl' ? dir : undefined)
86
- let query = $state('')
87
- const effectiveFilter = $derived(filter ?? (searchable ? query : undefined))
88
-
89
- // Sort siblings (recursively) before anything else.
90
- const sortedNodes = $derived.by(() => {
91
- if (!sort) return nodes
92
- const cmp = typeof sort === 'function' ? sort : (a: TreeNode, b: TreeNode) => (sort === 'asc' ? 1 : -1) * a.label.localeCompare(b.label)
93
- return sortTreeNodes(nodes, cmp)
94
- })
95
-
96
- // --- Lazy loading: merge loaded children into the (controlled) node tree -----
97
- let loaded = $state(new Map<string, TreeNode[]>())
98
- let loadingIds = $state(new Set<string>())
99
-
100
- function inject(list: ReadonlyArray<TreeNode>): TreeNode[] {
101
- return list.map((n) => {
102
- if (n.lazy && loaded.has(n.id)) return { ...n, children: inject(loaded.get(n.id)!), lazy: false }
103
- if (n.children?.length) return { ...n, children: inject(n.children) }
104
- return n
105
- })
106
- }
107
- const effectiveNodes = $derived(inject(sortedNodes))
108
-
109
- // --- Inline rename ----------------------------------------------------------
110
- let editingId = $state<string | null>(null)
111
- let draftLabel = $state('')
112
- function startEdit(node: TreeNode) {
113
- if (!editable || node.disabled) return
114
- editingId = node.id
115
- draftLabel = node.label
116
- tick().then(() => treeEl?.querySelector<HTMLInputElement>('.sv-tree__edit')?.select())
117
- }
118
- function commitEdit() {
119
- if (editingId == null) return
120
- const id = editingId
121
- const label = draftLabel.trim()
122
- editingId = null
123
- if (label) onRename?.(id, label)
124
- }
125
- function cancelEdit() { editingId = null }
126
-
127
- // --- Drag-drop reorder ------------------------------------------------------
128
- let dragId = $state<string | null>(null)
129
- let dropId = $state<string | null>(null)
130
- let dropPos = $state<TreeDropPosition | null>(null)
131
- function computeDropPos(e: DragEvent, node: TreeNode): TreeDropPosition {
132
- const el = e.currentTarget as HTMLElement
133
- const r = el.getBoundingClientRect()
134
- const y = (e.clientY - r.top) / r.height
135
- const canNest = !!node.children?.length || !!node.lazy
136
- if (y < 0.3) return 'before'
137
- if (y > 0.7 || !canNest) return y > 0.5 ? 'after' : (canNest ? 'inside' : 'after')
138
- return 'inside'
139
- }
140
- function onRowDragStart(e: DragEvent, node: TreeNode) {
141
- dragId = node.id
142
- if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
143
- }
144
- function onRowDragOver(e: DragEvent, node: TreeNode) {
145
- if (!dragId || dragId === node.id) return
146
- e.preventDefault()
147
- dropId = node.id
148
- dropPos = computeDropPos(e, node)
149
- }
150
- function onRowDrop(e: DragEvent, node: TreeNode) {
151
- e.preventDefault()
152
- if (dragId && dragId !== node.id && dropPos) onMove?.(dragId, node.id, dropPos)
153
- dragId = dropId = dropPos = null
154
- }
155
- function endDrag() { dragId = dropId = dropPos = null }
156
-
157
- function findNode(list: ReadonlyArray<TreeNode>, id: string): TreeNode | null {
158
- for (const n of list) {
159
- if (n.id === id) return n
160
- if (n.children) { const f = findNode(n.children, id); if (f) return f }
161
- }
162
- return null
163
- }
164
-
165
- async function handleToggle(id: string, expanded: boolean) {
166
- onToggle?.(id, expanded)
167
- if (!expanded || !loadChildren) return
168
- // Search the merged tree, not the raw prop: a nested lazy node (revealed by
169
- // a parent's earlier load) lives inside `loaded`, never in `nodes`.
170
- const node = findNode(effectiveNodes, id)
171
- if (!node?.lazy || loaded.has(id) || loadingIds.has(id)) return
172
- loadingIds = new Set(loadingIds).add(id)
173
- // A placeholder row shows immediately while the promise resolves.
174
- loaded = new Map(loaded).set(id, [{ id: `${id}__loading`, label: 'Loading...', disabled: true }])
175
- try {
176
- const kids = await loadChildren(node)
177
- loaded = new Map(loaded).set(id, kids)
178
- } catch {
179
- loaded = new Map(loaded).set(id, [])
180
- } finally {
181
- const next = new Set(loadingIds); next.delete(id); loadingIds = next
182
- }
183
- }
184
-
185
- const tree = createTree({
186
- nodes: () => effectiveNodes,
187
- selected: () => selected,
188
- onSelect: (id) => onSelect?.(id),
189
- expandedIds: () => expandedIds,
190
- onToggle: handleToggle,
191
- checkable: () => checkable,
192
- checked: () => checked,
193
- onCheck: (ids) => onCheck?.(ids),
194
- ariaLabel: () => ariaLabel,
195
- dir: () => dir,
196
- filter: () => effectiveFilter,
197
- })
198
-
199
- // --- Virtualization ---------------------------------------------------------
200
- const scrollable = $derived(virtual || height != null)
201
- let treeEl = $state<HTMLDivElement | null>(null)
202
- let scrollTop = $state(0)
203
- let viewportH = $state(0)
204
- const vr = $derived(
205
- virtualRange({ scrollTop, viewportHeight: viewportH, rowHeight, count: tree.rows.length, overscan: 10 }),
206
- )
207
- const visibleRows = $derived(virtual ? tree.rows.slice(vr.start, vr.end) : tree.rows)
208
-
209
- // Follow the core's roving focus row; scroll it into view first when windowed.
210
- let lastFocusTick = 0
211
- $effect(() => {
212
- if (tree.focusTick === lastFocusTick) return
213
- lastFocusTick = tree.focusTick
214
- const i = tree.activeIndex
215
- if (virtual && treeEl) {
216
- const next = scrollToIndex(i, scrollTop, treeEl.clientHeight, rowHeight)
217
- if (next !== scrollTop) { treeEl.scrollTop = next; scrollTop = next }
218
- }
219
- tick().then(() => treeEl?.querySelector<HTMLElement>(`[data-row="${i}"]`)?.focus())
220
- })
221
- </script>
222
-
223
- <div class="sv-tree-host" class:has-search={searchable} dir={resolvedDir}>
224
- {#if searchable}
225
- <div class="sv-tree__search">
226
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
227
- <input class="sv-tree__search-input" type="text" placeholder={searchPlaceholder} bind:value={query} aria-label={searchPlaceholder} />
228
- {#if query}<button type="button" class="sv-tree__search-clear" aria-label="Clear search" onclick={() => (query = '')}>&times;</button>{/if}
229
- </div>
230
- {/if}
231
- <div
232
- bind:this={treeEl}
233
- class="sv-tree"
234
- class:sv-tree--scroll={scrollable}
235
- class:is-virtual={virtual}
236
- dir={resolvedDir}
237
- style:height={height != null ? `${height}px` : undefined}
238
- onscroll={scrollable ? (e) => { scrollTop = e.currentTarget.scrollTop; if (virtual) flushSync() } : undefined}
239
- bind:clientHeight={viewportH}
240
- {...tree.treeProps()}
241
- >
242
- {#if virtual}<div class="sv-tree__sizer" aria-hidden="true" style:height={`${vr.totalHeight}px`}></div>{/if}
243
- {#each visibleRows as item (item.node.id)}
244
- {@render treeRow(item)}
245
- {/each}
246
- </div>
247
- </div>
248
-
249
- {#snippet treeRow(item)}
250
- {@const cs = checkable ? tree.checkStateOf(item.node) : 'unchecked'}
251
- {@const isLoading = loadingIds.has(item.node.id)}
252
- {@const rowProps = tree.itemProps(item)}
253
- <div
254
- class="sv-tree__row"
255
- class:is-selected={item.node.id === selected}
256
- class:is-disabled={item.node.disabled}
257
- class:is-dragging={dragId === item.node.id}
258
- class:drop-before={dropId === item.node.id && dropPos === 'before'}
259
- class:drop-after={dropId === item.node.id && dropPos === 'after'}
260
- class:drop-inside={dropId === item.node.id && dropPos === 'inside'}
261
- style:height={`${rowHeight}px`}
262
- style:padding-inline-start={`${item.depth * 18 + 6}px`}
263
- style:transform={virtual ? `translateY(${item.index * rowHeight}px)` : undefined}
264
- draggable={reorderable && editingId !== item.node.id && !item.node.disabled}
265
- ondragstart={reorderable ? (e) => onRowDragStart(e, item.node) : undefined}
266
- ondragover={reorderable ? (e) => onRowDragOver(e, item.node) : undefined}
267
- ondragleave={reorderable ? () => { if (dropId === item.node.id) { dropId = null; dropPos = null } } : undefined}
268
- ondrop={reorderable ? (e) => onRowDrop(e, item.node) : undefined}
269
- ondragend={reorderable ? endDrag : undefined}
270
- ondblclick={editable ? () => startEdit(item.node) : undefined}
271
- {...rowProps}
272
- onkeydown={(e) => {
273
- rowProps.onkeydown(e)
274
- if (editable && e.key === 'F2') { e.preventDefault(); startEdit(item.node) }
275
- }}
276
- >
277
- <button
278
- type="button"
279
- class="sv-tree__twist"
280
- class:is-open={item.open}
281
- class:is-leaf={!item.hasChildren}
282
- class:is-loading={isLoading}
283
- tabindex="-1"
284
- aria-hidden="true"
285
- onclick={(e) => { e.stopPropagation(); tree.toggleExpand(item.node) }}
286
- >
287
- {#if isLoading}
288
- <svg class="sv-tree__spin" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.6" /></svg>
289
- {:else if item.hasChildren}
290
- <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>
291
- {/if}
292
- </button>
293
- {#if checkable}
294
- <button type="button" class="sv-tree__check is-{cs}" tabindex="-1" aria-hidden="true" onclick={(e) => { e.stopPropagation(); tree.toggleCheck(item.node) }}>
295
- {#if cs === 'checked'}<svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-6.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" /></svg>
296
- {:else if cs === 'indeterminate'}<svg viewBox="0 0 16 16"><path d="M4 8h8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" /></svg>{/if}
297
- </button>
298
- {/if}
299
- {#if editingId === item.node.id}
300
- <!-- svelte-ignore a11y_autofocus -->
301
- <input
302
- class="sv-tree__edit"
303
- bind:value={draftLabel}
304
- onclick={(e) => e.stopPropagation()}
305
- onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitEdit() } else if (e.key === 'Escape') { e.preventDefault(); cancelEdit() } }}
306
- onblur={commitEdit}
307
- />
308
- {:else}
309
- <span class="sv-tree__label">{item.node.label}</span>
310
- {/if}
311
- </div>
312
- {/snippet}
313
-
314
- <style>
315
- .sv-tree-host { display: contents; }
316
- .sv-tree-host.has-search { display: inline-flex; flex-direction: column; gap: 6px; }
317
- .sv-tree__search {
318
- display: flex; align-items: center; gap: 7px; width: 260px; box-sizing: border-box; padding: 0 10px; height: 32px;
319
- background: var(--sg-input-bg, #fff); color: var(--sg-muted, #64748b);
320
- border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
321
- }
322
- .sv-tree__search:focus-within { border-color: var(--sg-accent, #2563eb); box-shadow: 0 0 0 2px color-mix(in srgb, var(--sg-accent, #2563eb) 22%, transparent); }
323
- .sv-tree__search-input { flex: 1; min-width: 0; border: 0; background: none; outline: none; color: var(--sg-fg, #0f172a); font: inherit; font-size: 13px; }
324
- .sv-tree__search-clear { background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px; }
325
- .sv-tree {
326
- --_accent: var(--sg-accent, #2563eb);
327
- width: 260px; padding: 4px; user-select: none;
328
- background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
329
- border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
330
- font-size: 13px;
331
- }
332
- .sv-tree--scroll { overflow-y: auto; }
333
- .sv-tree.is-virtual { position: relative; will-change: scroll-position; }
334
- .sv-tree__sizer { padding: 0; margin: 0; pointer-events: none; }
335
- .sv-tree.is-virtual .sv-tree__row { position: absolute; inset-inline: 4px; top: 4px; }
336
- .sv-tree__row {
337
- display: flex; align-items: center; gap: 4px; box-sizing: border-box; padding-inline-end: 8px;
338
- border-radius: 6px; cursor: pointer; outline: none;
339
- }
340
- .sv-tree__row:hover:not(.is-disabled) { background: var(--sg-row-hover-bg, #f1f5f9); }
341
- .sv-tree__row.is-selected { background: color-mix(in srgb, var(--_accent) 15%, transparent); color: var(--_accent); font-weight: 600; }
342
- .sv-tree__row.is-disabled { opacity: 0.45; cursor: not-allowed; }
343
- .sv-tree__row:focus-visible { box-shadow: inset 0 0 0 2px var(--sg-focus-ring, var(--_accent)); }
344
- .sv-tree__twist {
345
- display: grid; place-items: center; width: 18px; height: 18px; flex: none;
346
- background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; transition: transform 0.12s;
347
- }
348
- .sv-tree__twist.is-open { transform: rotate(90deg); }
349
- .sv-tree[dir='rtl'] .sv-tree__twist:not(.is-open) svg { transform: scaleX(-1); }
350
- .sv-tree__twist.is-leaf { visibility: hidden; }
351
- .sv-tree__twist.is-loading { transform: none; }
352
- .sv-tree__spin { animation: sv-tree-spin 0.7s linear infinite; }
353
- @keyframes sv-tree-spin { to { transform: rotate(360deg); } }
354
- @media (prefers-reduced-motion: reduce) { .sv-tree__spin { animation: none; } }
355
- .sv-tree__check {
356
- display: grid; place-items: center; width: 16px; height: 16px; flex: none; padding: 0;
357
- border: 1.5px solid var(--sg-border, #cbd5e1); border-radius: 4px; background: var(--sg-input-bg, #fff);
358
- color: var(--sg-on-accent, #fff); cursor: pointer;
359
- }
360
- .sv-tree__check.is-checked, .sv-tree__check.is-indeterminate { background: var(--_accent); border-color: var(--_accent); }
361
- .sv-tree__check svg { width: 100%; height: 100%; }
362
- .sv-tree__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
363
- .sv-tree__edit {
364
- flex: 1; min-width: 0; height: 22px; padding: 0 6px; margin: 0; font: inherit; font-size: 13px;
365
- border: 1px solid var(--_accent); border-radius: 5px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); outline: none;
366
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent);
367
- }
368
- /* Drag-drop feedback */
369
- .sv-tree__row.is-dragging { opacity: 0.4; }
370
- .sv-tree__row.drop-inside { background: color-mix(in srgb, var(--_accent) 18%, transparent); box-shadow: inset 0 0 0 1px var(--_accent); }
371
- .sv-tree__row.drop-before, .sv-tree__row.drop-after { position: relative; }
372
- .sv-tree__row.drop-before::before, .sv-tree__row.drop-after::after {
373
- content: ''; position: absolute; inset-inline: 4px; height: 2px; background: var(--_accent); border-radius: 2px; z-index: 1;
374
- }
375
- .sv-tree__row.drop-before::before { top: -1px; }
376
- .sv-tree__row.drop-after::after { bottom: -1px; }
377
- </style>
1
+ <script lang="ts" module>
2
+ export type { TreeNode } from './createTree.svelte'
3
+ </script>
4
+
5
+ <script lang="ts">
6
+ /**
7
+ * SvTree - a WAI-ARIA tree view. Expand/collapse, single-select highlight,
8
+ * cascading tri-state checkboxes, keyboard, and - for large or remote data -
9
+ * fixed-row `virtual` windowing (scales to tens of thousands of nodes) and
10
+ * `loadChildren` lazy-loading. Parity: Smart `smart-tree`.
11
+ *
12
+ * The behavior (flattening, cascade math, keyboard, ARIA) lives in the headless
13
+ * `createTree` core; this component renders it, and owns the DOM concerns
14
+ * (windowing, focus, and merging lazily-loaded children into the node tree).
15
+ */
16
+ import { tick, flushSync } from 'svelte'
17
+ import { type EditorDir } from './editor-contract'
18
+ import { virtualRange, scrollToIndex } from './virtual'
19
+ import { createTree, sortTreeNodes, type TreeNode, type TreeDropPosition } from './createTree.svelte'
20
+
21
+ type Props = {
22
+ nodes: ReadonlyArray<TreeNode>
23
+ /** Selected node id (single-select highlight). */
24
+ selected?: string | null
25
+ onSelect?: (id: string) => void
26
+ /** Initially/controlled expanded ids. */
27
+ expandedIds?: string[]
28
+ onToggle?: (id: string, expanded: boolean) => void
29
+ /** Show cascading checkboxes; `checked` is the set of checked ids. */
30
+ checkable?: boolean
31
+ checked?: string[]
32
+ onCheck?: (ids: string[]) => void
33
+ ariaLabel?: string
34
+ /** Text direction (rtl mirrors indentation + flips Left/Right keys). */
35
+ dir?: EditorDir
36
+ /** Window rows (render only visible) - for large trees. Needs `height`. */
37
+ virtual?: boolean
38
+ /** Fixed row height in px (must match the CSS). Default 30. */
39
+ rowHeight?: number
40
+ /** Scroll-viewport height in px. Enables scrolling (required for `virtual`). */
41
+ height?: number
42
+ /** Load a lazy node's children on first expand (node has `lazy: true`). */
43
+ loadChildren?: (node: TreeNode) => Promise<TreeNode[]>
44
+ /** Filter query - show only matching nodes + ancestors, auto-expanded. */
45
+ filter?: string
46
+ /** Render a built-in search box that drives the filter. */
47
+ searchable?: boolean
48
+ searchPlaceholder?: string
49
+ /** Sort siblings by label ('asc'/'desc') or a custom comparator. */
50
+ sort?: 'asc' | 'desc' | ((a: TreeNode, b: TreeNode) => number)
51
+ /** Allow inline rename (double-click or F2). Fires onRename. */
52
+ editable?: boolean
53
+ onRename?: (id: string, label: string) => void
54
+ /** Allow drag-drop reordering; fires onMove(dragId, targetId, position).
55
+ * Apply it with the exported `moveTreeNode(nodes, ...)` helper. */
56
+ reorderable?: boolean
57
+ onMove?: (dragId: string, targetId: string, position: TreeDropPosition) => void
58
+ }
59
+
60
+ let {
61
+ nodes,
62
+ selected = null,
63
+ onSelect,
64
+ expandedIds,
65
+ onToggle,
66
+ checkable = false,
67
+ checked = [],
68
+ onCheck,
69
+ ariaLabel,
70
+ dir,
71
+ virtual = false,
72
+ rowHeight = 30,
73
+ height,
74
+ loadChildren,
75
+ filter,
76
+ searchable = false,
77
+ searchPlaceholder = 'Search...',
78
+ sort,
79
+ editable = false,
80
+ onRename,
81
+ reorderable = false,
82
+ onMove,
83
+ }: Props = $props()
84
+
85
+ const resolvedDir = $derived(dir === 'ltr' || dir === 'rtl' ? dir : undefined)
86
+ let query = $state('')
87
+ const effectiveFilter = $derived(filter ?? (searchable ? query : undefined))
88
+
89
+ // Sort siblings (recursively) before anything else.
90
+ const sortedNodes = $derived.by(() => {
91
+ if (!sort) return nodes
92
+ const cmp = typeof sort === 'function' ? sort : (a: TreeNode, b: TreeNode) => (sort === 'asc' ? 1 : -1) * a.label.localeCompare(b.label)
93
+ return sortTreeNodes(nodes, cmp)
94
+ })
95
+
96
+ // --- Lazy loading: merge loaded children into the (controlled) node tree -----
97
+ let loaded = $state(new Map<string, TreeNode[]>())
98
+ let loadingIds = $state(new Set<string>())
99
+
100
+ function inject(list: ReadonlyArray<TreeNode>): TreeNode[] {
101
+ return list.map((n) => {
102
+ if (n.lazy && loaded.has(n.id)) return { ...n, children: inject(loaded.get(n.id)!), lazy: false }
103
+ if (n.children?.length) return { ...n, children: inject(n.children) }
104
+ return n
105
+ })
106
+ }
107
+ const effectiveNodes = $derived(inject(sortedNodes))
108
+
109
+ // --- Inline rename ----------------------------------------------------------
110
+ let editingId = $state<string | null>(null)
111
+ let draftLabel = $state('')
112
+ function startEdit(node: TreeNode) {
113
+ if (!editable || node.disabled) return
114
+ editingId = node.id
115
+ draftLabel = node.label
116
+ tick().then(() => treeEl?.querySelector<HTMLInputElement>('.sv-tree__edit')?.select())
117
+ }
118
+ function commitEdit() {
119
+ if (editingId == null) return
120
+ const id = editingId
121
+ const label = draftLabel.trim()
122
+ editingId = null
123
+ if (label) onRename?.(id, label)
124
+ }
125
+ function cancelEdit() { editingId = null }
126
+
127
+ // --- Drag-drop reorder ------------------------------------------------------
128
+ let dragId = $state<string | null>(null)
129
+ let dropId = $state<string | null>(null)
130
+ let dropPos = $state<TreeDropPosition | null>(null)
131
+ function computeDropPos(e: DragEvent, node: TreeNode): TreeDropPosition {
132
+ const el = e.currentTarget as HTMLElement
133
+ const r = el.getBoundingClientRect()
134
+ const y = (e.clientY - r.top) / r.height
135
+ const canNest = !!node.children?.length || !!node.lazy
136
+ if (y < 0.3) return 'before'
137
+ if (y > 0.7 || !canNest) return y > 0.5 ? 'after' : (canNest ? 'inside' : 'after')
138
+ return 'inside'
139
+ }
140
+ function onRowDragStart(e: DragEvent, node: TreeNode) {
141
+ dragId = node.id
142
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
143
+ }
144
+ function onRowDragOver(e: DragEvent, node: TreeNode) {
145
+ if (!dragId || dragId === node.id) return
146
+ e.preventDefault()
147
+ dropId = node.id
148
+ dropPos = computeDropPos(e, node)
149
+ }
150
+ function onRowDrop(e: DragEvent, node: TreeNode) {
151
+ e.preventDefault()
152
+ if (dragId && dragId !== node.id && dropPos) onMove?.(dragId, node.id, dropPos)
153
+ dragId = dropId = dropPos = null
154
+ }
155
+ function endDrag() { dragId = dropId = dropPos = null }
156
+
157
+ function findNode(list: ReadonlyArray<TreeNode>, id: string): TreeNode | null {
158
+ for (const n of list) {
159
+ if (n.id === id) return n
160
+ if (n.children) { const f = findNode(n.children, id); if (f) return f }
161
+ }
162
+ return null
163
+ }
164
+
165
+ async function handleToggle(id: string, expanded: boolean) {
166
+ onToggle?.(id, expanded)
167
+ if (!expanded || !loadChildren) return
168
+ // Search the merged tree, not the raw prop: a nested lazy node (revealed by
169
+ // a parent's earlier load) lives inside `loaded`, never in `nodes`.
170
+ const node = findNode(effectiveNodes, id)
171
+ if (!node?.lazy || loaded.has(id) || loadingIds.has(id)) return
172
+ loadingIds = new Set(loadingIds).add(id)
173
+ // A placeholder row shows immediately while the promise resolves.
174
+ loaded = new Map(loaded).set(id, [{ id: `${id}__loading`, label: 'Loading...', disabled: true }])
175
+ try {
176
+ const kids = await loadChildren(node)
177
+ loaded = new Map(loaded).set(id, kids)
178
+ } catch {
179
+ loaded = new Map(loaded).set(id, [])
180
+ } finally {
181
+ const next = new Set(loadingIds); next.delete(id); loadingIds = next
182
+ }
183
+ }
184
+
185
+ const tree = createTree({
186
+ nodes: () => effectiveNodes,
187
+ selected: () => selected,
188
+ onSelect: (id) => onSelect?.(id),
189
+ expandedIds: () => expandedIds,
190
+ onToggle: handleToggle,
191
+ checkable: () => checkable,
192
+ checked: () => checked,
193
+ onCheck: (ids) => onCheck?.(ids),
194
+ ariaLabel: () => ariaLabel,
195
+ dir: () => dir,
196
+ filter: () => effectiveFilter,
197
+ })
198
+
199
+ // --- Virtualization ---------------------------------------------------------
200
+ const scrollable = $derived(virtual || height != null)
201
+ let treeEl = $state<HTMLDivElement | null>(null)
202
+ let scrollTop = $state(0)
203
+ let viewportH = $state(0)
204
+ const vr = $derived(
205
+ virtualRange({ scrollTop, viewportHeight: viewportH, rowHeight, count: tree.rows.length, overscan: 10 }),
206
+ )
207
+ const visibleRows = $derived(virtual ? tree.rows.slice(vr.start, vr.end) : tree.rows)
208
+
209
+ // Follow the core's roving focus row; scroll it into view first when windowed.
210
+ let lastFocusTick = 0
211
+ $effect(() => {
212
+ if (tree.focusTick === lastFocusTick) return
213
+ lastFocusTick = tree.focusTick
214
+ const i = tree.activeIndex
215
+ if (virtual && treeEl) {
216
+ const next = scrollToIndex(i, scrollTop, treeEl.clientHeight, rowHeight)
217
+ if (next !== scrollTop) { treeEl.scrollTop = next; scrollTop = next }
218
+ }
219
+ tick().then(() => treeEl?.querySelector<HTMLElement>(`[data-row="${i}"]`)?.focus())
220
+ })
221
+ </script>
222
+
223
+ <div class="sv-tree-host" class:has-search={searchable} dir={resolvedDir}>
224
+ {#if searchable}
225
+ <div class="sv-tree__search">
226
+ <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
227
+ <input class="sv-tree__search-input" type="text" placeholder={searchPlaceholder} bind:value={query} aria-label={searchPlaceholder} />
228
+ {#if query}<button type="button" class="sv-tree__search-clear" aria-label="Clear search" onclick={() => (query = '')}>&times;</button>{/if}
229
+ </div>
230
+ {/if}
231
+ <div
232
+ bind:this={treeEl}
233
+ class="sv-tree"
234
+ class:sv-tree--scroll={scrollable}
235
+ class:is-virtual={virtual}
236
+ dir={resolvedDir}
237
+ style:height={height != null ? `${height}px` : undefined}
238
+ onscroll={scrollable ? (e) => { scrollTop = e.currentTarget.scrollTop; if (virtual) flushSync() } : undefined}
239
+ bind:clientHeight={viewportH}
240
+ {...tree.treeProps()}
241
+ >
242
+ {#if virtual}<div class="sv-tree__sizer" aria-hidden="true" style:height={`${vr.totalHeight}px`}></div>{/if}
243
+ {#each visibleRows as item (item.node.id)}
244
+ {@render treeRow(item)}
245
+ {/each}
246
+ </div>
247
+ </div>
248
+
249
+ {#snippet treeRow(item)}
250
+ {@const cs = checkable ? tree.checkStateOf(item.node) : 'unchecked'}
251
+ {@const isLoading = loadingIds.has(item.node.id)}
252
+ {@const rowProps = tree.itemProps(item)}
253
+ <div
254
+ class="sv-tree__row"
255
+ class:is-selected={item.node.id === selected}
256
+ class:is-disabled={item.node.disabled}
257
+ class:is-dragging={dragId === item.node.id}
258
+ class:drop-before={dropId === item.node.id && dropPos === 'before'}
259
+ class:drop-after={dropId === item.node.id && dropPos === 'after'}
260
+ class:drop-inside={dropId === item.node.id && dropPos === 'inside'}
261
+ style:height={`${rowHeight}px`}
262
+ style:padding-inline-start={`${item.depth * 18 + 6}px`}
263
+ style:transform={virtual ? `translateY(${item.index * rowHeight}px)` : undefined}
264
+ draggable={reorderable && editingId !== item.node.id && !item.node.disabled}
265
+ ondragstart={reorderable ? (e) => onRowDragStart(e, item.node) : undefined}
266
+ ondragover={reorderable ? (e) => onRowDragOver(e, item.node) : undefined}
267
+ ondragleave={reorderable ? () => { if (dropId === item.node.id) { dropId = null; dropPos = null } } : undefined}
268
+ ondrop={reorderable ? (e) => onRowDrop(e, item.node) : undefined}
269
+ ondragend={reorderable ? endDrag : undefined}
270
+ ondblclick={editable ? () => startEdit(item.node) : undefined}
271
+ {...rowProps}
272
+ onkeydown={(e) => {
273
+ rowProps.onkeydown(e)
274
+ if (editable && e.key === 'F2') { e.preventDefault(); startEdit(item.node) }
275
+ }}
276
+ >
277
+ <button
278
+ type="button"
279
+ class="sv-tree__twist"
280
+ class:is-open={item.open}
281
+ class:is-leaf={!item.hasChildren}
282
+ class:is-loading={isLoading}
283
+ tabindex="-1"
284
+ aria-hidden="true"
285
+ onclick={(e) => { e.stopPropagation(); tree.toggleExpand(item.node) }}
286
+ >
287
+ {#if isLoading}
288
+ <svg class="sv-tree__spin" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-6.2-8.6" /></svg>
289
+ {:else if item.hasChildren}
290
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>
291
+ {/if}
292
+ </button>
293
+ {#if checkable}
294
+ <button type="button" class="sv-tree__check is-{cs}" tabindex="-1" aria-hidden="true" onclick={(e) => { e.stopPropagation(); tree.toggleCheck(item.node) }}>
295
+ {#if cs === 'checked'}<svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-6.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" /></svg>
296
+ {:else if cs === 'indeterminate'}<svg viewBox="0 0 16 16"><path d="M4 8h8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" /></svg>{/if}
297
+ </button>
298
+ {/if}
299
+ {#if editingId === item.node.id}
300
+ <!-- svelte-ignore a11y_autofocus -->
301
+ <input
302
+ class="sv-tree__edit"
303
+ bind:value={draftLabel}
304
+ onclick={(e) => e.stopPropagation()}
305
+ onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); commitEdit() } else if (e.key === 'Escape') { e.preventDefault(); cancelEdit() } }}
306
+ onblur={commitEdit}
307
+ />
308
+ {:else}
309
+ <span class="sv-tree__label">{item.node.label}</span>
310
+ {/if}
311
+ </div>
312
+ {/snippet}
313
+
314
+ <style>
315
+ .sv-tree-host { display: contents; }
316
+ .sv-tree-host.has-search { display: inline-flex; flex-direction: column; gap: 6px; }
317
+ .sv-tree__search {
318
+ display: flex; align-items: center; gap: 7px; width: 260px; box-sizing: border-box; padding: 0 10px; height: 32px;
319
+ background: var(--sg-input-bg, #fff); color: var(--sg-muted, #64748b);
320
+ border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1)); border-radius: var(--sg-radius, 8px);
321
+ }
322
+ .sv-tree__search:focus-within { border-color: var(--sg-accent, #2563eb); box-shadow: 0 0 0 2px color-mix(in srgb, var(--sg-accent, #2563eb) 22%, transparent); }
323
+ .sv-tree__search-input { flex: 1; min-width: 0; border: 0; background: none; outline: none; color: var(--sg-fg, #0f172a); font: inherit; font-size: 13px; }
324
+ .sv-tree__search-clear { background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; font-size: 16px; line-height: 1; padding: 0 2px; }
325
+ .sv-tree {
326
+ --_accent: var(--sg-accent, #2563eb);
327
+ width: 260px; padding: 4px; user-select: none;
328
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
329
+ border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
330
+ font-size: 13px;
331
+ }
332
+ .sv-tree--scroll { overflow-y: auto; }
333
+ .sv-tree.is-virtual { position: relative; will-change: scroll-position; }
334
+ .sv-tree__sizer { padding: 0; margin: 0; pointer-events: none; }
335
+ .sv-tree.is-virtual .sv-tree__row { position: absolute; inset-inline: 4px; top: 4px; }
336
+ .sv-tree__row {
337
+ display: flex; align-items: center; gap: 4px; box-sizing: border-box; padding-inline-end: 8px;
338
+ border-radius: 6px; cursor: pointer; outline: none;
339
+ }
340
+ .sv-tree__row:hover:not(.is-disabled) { background: var(--sg-row-hover-bg, #f1f5f9); }
341
+ .sv-tree__row.is-selected { background: color-mix(in srgb, var(--_accent) 15%, transparent); color: var(--_accent); font-weight: 600; }
342
+ .sv-tree__row.is-disabled { opacity: 0.45; cursor: not-allowed; }
343
+ .sv-tree__row:focus-visible { box-shadow: inset 0 0 0 2px var(--sg-focus-ring, var(--_accent)); }
344
+ .sv-tree__twist {
345
+ display: grid; place-items: center; width: 18px; height: 18px; flex: none;
346
+ background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; transition: transform 0.12s;
347
+ }
348
+ .sv-tree__twist.is-open { transform: rotate(90deg); }
349
+ .sv-tree[dir='rtl'] .sv-tree__twist:not(.is-open) svg { transform: scaleX(-1); }
350
+ .sv-tree__twist.is-leaf { visibility: hidden; }
351
+ .sv-tree__twist.is-loading { transform: none; }
352
+ .sv-tree__spin { animation: sv-tree-spin 0.7s linear infinite; }
353
+ @keyframes sv-tree-spin { to { transform: rotate(360deg); } }
354
+ @media (prefers-reduced-motion: reduce) { .sv-tree__spin { animation: none; } }
355
+ .sv-tree__check {
356
+ display: grid; place-items: center; width: 16px; height: 16px; flex: none; padding: 0;
357
+ border: 1.5px solid var(--sg-border, #cbd5e1); border-radius: 4px; background: var(--sg-input-bg, #fff);
358
+ color: var(--sg-on-accent, #fff); cursor: pointer;
359
+ }
360
+ .sv-tree__check.is-checked, .sv-tree__check.is-indeterminate { background: var(--_accent); border-color: var(--_accent); }
361
+ .sv-tree__check svg { width: 100%; height: 100%; }
362
+ .sv-tree__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
363
+ .sv-tree__edit {
364
+ flex: 1; min-width: 0; height: 22px; padding: 0 6px; margin: 0; font: inherit; font-size: 13px;
365
+ border: 1px solid var(--_accent); border-radius: 5px; background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a); outline: none;
366
+ box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent);
367
+ }
368
+ /* Drag-drop feedback */
369
+ .sv-tree__row.is-dragging { opacity: 0.4; }
370
+ .sv-tree__row.drop-inside { background: color-mix(in srgb, var(--_accent) 18%, transparent); box-shadow: inset 0 0 0 1px var(--_accent); }
371
+ .sv-tree__row.drop-before, .sv-tree__row.drop-after { position: relative; }
372
+ .sv-tree__row.drop-before::before, .sv-tree__row.drop-after::after {
373
+ content: ''; position: absolute; inset-inline: 4px; height: 2px; background: var(--_accent); border-radius: 2px; z-index: 1;
374
+ }
375
+ .sv-tree__row.drop-before::before { top: -1px; }
376
+ .sv-tree__row.drop-after::after { bottom: -1px; }
377
+ </style>