@svgrid/grid 2.2.27 → 2.2.29

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 (248) hide show
  1. package/README.md +199 -199
  2. package/dist/FlexRender.svelte +96 -96
  3. package/dist/GridFooter.svelte +179 -179
  4. package/dist/GridMenus.svelte +782 -734
  5. package/dist/SvAutoComplete.svelte +165 -165
  6. package/dist/SvCalendar.svelte +503 -503
  7. package/dist/SvCarousel.svelte +141 -141
  8. package/dist/SvCheckBox.svelte +101 -101
  9. package/dist/SvColorInput.svelte +181 -181
  10. package/dist/SvComboBox.svelte +275 -275
  11. package/dist/SvContextMenu.svelte +116 -116
  12. package/dist/SvCountryInput.svelte +160 -160
  13. package/dist/SvDrawer.svelte +254 -250
  14. package/dist/SvDropDownList.svelte +374 -374
  15. package/dist/SvDurationInput.svelte +126 -126
  16. package/dist/SvField.svelte +293 -293
  17. package/dist/SvForm.svelte +437 -437
  18. package/dist/SvGrid.controller.svelte.d.ts +14 -1
  19. package/dist/SvGrid.controller.svelte.js +112 -28
  20. package/dist/SvGrid.css +2704 -2723
  21. package/dist/SvGrid.svelte +3361 -3333
  22. package/dist/SvGrid.types.d.ts +20 -5
  23. package/dist/SvGridChart.svelte +1724 -1724
  24. package/dist/SvGridChartPanel.svelte +485 -485
  25. package/dist/SvGridChartView.svelte +70 -70
  26. package/dist/SvGridDropdown.svelte +696 -696
  27. package/dist/SvGridSelect.svelte +259 -259
  28. package/dist/SvGroupCell.svelte +109 -109
  29. package/dist/SvListBox.svelte +326 -287
  30. package/dist/SvListBox.svelte.d.ts +10 -1
  31. package/dist/SvMaskedInput.svelte +122 -122
  32. package/dist/SvMenu.svelte +124 -124
  33. package/dist/SvMenuList.svelte +146 -146
  34. package/dist/SvMultiSelect.svelte +281 -281
  35. package/dist/SvNumberInput.svelte +168 -168
  36. package/dist/SvOtpInput.svelte +158 -158
  37. package/dist/SvPasswordInput.svelte +151 -151
  38. package/dist/SvPhoneInput.svelte +133 -131
  39. package/dist/SvPopover.svelte +197 -197
  40. package/dist/SvRadioGroup.svelte +107 -107
  41. package/dist/SvRowGroupPanel.svelte +170 -170
  42. package/dist/SvScrollArea.svelte +61 -61
  43. package/dist/SvSlider.svelte +200 -200
  44. package/dist/SvSwitchButton.svelte +107 -107
  45. package/dist/SvTagsInput.svelte +112 -112
  46. package/dist/SvTextInput.svelte +147 -147
  47. package/dist/SvTimePicker.svelte +245 -245
  48. package/dist/SvToaster.svelte +159 -159
  49. package/dist/SvToggleButton.svelte +85 -85
  50. package/dist/SvTooltip.svelte +161 -161
  51. package/dist/SvTour.svelte +204 -204
  52. package/dist/SvTree.svelte +437 -437
  53. package/dist/SvTreeSelect.svelte +233 -233
  54. package/dist/cdn/GridMenus-Da_p7SKS.js +486 -0
  55. package/dist/cdn/GridMenus-Ds3OpzZT.js +490 -0
  56. package/dist/cdn/{SvDateTimePicker-B1sDc9Pz.js → SvDateTimePicker-B8GopjhC.js} +2 -2
  57. package/dist/cdn/{SvDateTimePicker-Bh0FTs1E.js → SvDateTimePicker-DB0aIEk8.js} +2 -2
  58. package/dist/cdn/{SvGridChart-Bgk4hDs8.js → SvGridChart-BcLCYvw9.js} +1 -1
  59. package/dist/cdn/{SvGridChart-BKQsO5fZ.js → SvGridChart-BhnFdLd0.js} +1 -1
  60. package/dist/cdn/{SvGridChartPanel-DkCc2Z7b.js → SvGridChartPanel-Cmx9DHAS.js} +1 -1
  61. package/dist/cdn/{SvGridChartPanel-DSkHIQDd.js → SvGridChartPanel-DvjwxLvK.js} +1 -1
  62. package/dist/cdn/{SvGridChartView-BrbVaELm.js → SvGridChartView-B40DTJSX.js} +1 -1
  63. package/dist/cdn/{SvGridChartView-i9rWvSKg.js → SvGridChartView-PwqI4s02.js} +1 -1
  64. package/dist/cdn/{SvGridDropdown-D08sVmAy.js → SvGridDropdown-DjALc_4f.js} +1 -1
  65. package/dist/cdn/{SvGridDropdown-CGxxopQ4.js → SvGridDropdown-hpnHvOGt.js} +1 -1
  66. package/dist/cdn/{src-cgs1wcUS.js → src-BLJfdyL0.js} +4034 -3889
  67. package/dist/cdn/{src-Cw8d9U8j.js → src-DYDCiC0D.js} +7053 -6908
  68. package/dist/cdn/svgrid.js +9 -9
  69. package/dist/cdn/svgrid.svelte-external.js +9 -9
  70. package/dist/chart-export.js +8 -8
  71. package/dist/column-groups.js +8 -10
  72. package/dist/column-id.d.ts +9 -0
  73. package/dist/column-id.js +25 -0
  74. package/dist/core.js +2 -1
  75. package/dist/createListbox.svelte.d.ts +15 -2
  76. package/dist/createListbox.svelte.js +27 -6
  77. package/dist/editing.d.ts +1 -0
  78. package/dist/editing.js +45 -10
  79. package/dist/filtering/excel-filters.d.ts +30 -10
  80. package/dist/filtering/excel-filters.js +96 -15
  81. package/dist/index.d.ts +2 -2
  82. package/dist/index.js +2 -2
  83. package/dist/menus.d.ts +2 -0
  84. package/dist/menus.js +25 -0
  85. package/dist/row-resize.js +40 -0
  86. package/dist/selection.js +1 -1
  87. package/package.json +10 -10
  88. package/src/FlexRender.svelte +96 -96
  89. package/src/GridFooter.svelte +179 -179
  90. package/src/GridMenus.svelte +782 -734
  91. package/src/SvAutoComplete.svelte +165 -165
  92. package/src/SvCalendar.svelte +503 -503
  93. package/src/SvCalendar.test.ts +226 -226
  94. package/src/SvCarousel.svelte +141 -141
  95. package/src/SvCheckBox.svelte +101 -101
  96. package/src/SvColorInput.svelte +181 -181
  97. package/src/SvComboBox.svelte +275 -275
  98. package/src/SvContextMenu.svelte +116 -116
  99. package/src/SvCountryInput.svelte +160 -160
  100. package/src/SvDrawer.svelte +254 -250
  101. package/src/SvDrawer.test.ts +18 -1
  102. package/src/SvDropDownList.svelte +374 -374
  103. package/src/SvDurationInput.svelte +126 -126
  104. package/src/SvField.svelte +293 -293
  105. package/src/SvForm.svelte +437 -437
  106. package/src/SvForm.test.ts +411 -411
  107. package/src/SvGrid.controller.svelte.ts +3335 -3239
  108. package/src/SvGrid.css +2704 -2723
  109. package/src/SvGrid.svelte +3361 -3333
  110. package/src/SvGrid.types.ts +1628 -1613
  111. package/src/SvGridChart.svelte +1724 -1724
  112. package/src/SvGridChartPanel.svelte +485 -485
  113. package/src/SvGridChartView.svelte +70 -70
  114. package/src/SvGridDropdown.svelte +696 -696
  115. package/src/SvGridSelect.svelte +259 -259
  116. package/src/SvGroupCell.svelte +109 -109
  117. package/src/SvListBox.svelte +326 -287
  118. package/src/SvMaskedInput.svelte +122 -122
  119. package/src/SvMenu.svelte +124 -124
  120. package/src/SvMenu.test.ts +97 -97
  121. package/src/SvMenuList.svelte +146 -146
  122. package/src/SvMultiSelect.svelte +281 -281
  123. package/src/SvNavPane.test.ts +15 -4
  124. package/src/SvNumberInput.svelte +168 -168
  125. package/src/SvOtpInput.svelte +158 -158
  126. package/src/SvPasswordInput.svelte +151 -151
  127. package/src/SvPhoneInput.svelte +133 -131
  128. package/src/SvPopover.svelte +197 -197
  129. package/src/SvRadioGroup.svelte +107 -107
  130. package/src/SvRowGroupPanel.svelte +170 -170
  131. package/src/SvScrollArea.svelte +61 -61
  132. package/src/SvSlider.svelte +200 -200
  133. package/src/SvSwitchButton.svelte +107 -107
  134. package/src/SvTagsInput.svelte +112 -112
  135. package/src/SvTextInput.svelte +147 -147
  136. package/src/SvTimePicker.svelte +245 -245
  137. package/src/SvToaster.svelte +159 -159
  138. package/src/SvToaster.test.ts +95 -95
  139. package/src/SvToggleButton.svelte +85 -85
  140. package/src/SvTooltip.svelte +161 -161
  141. package/src/SvTour.svelte +204 -204
  142. package/src/SvTree.svelte +437 -437
  143. package/src/SvTreeSelect.svelte +233 -233
  144. package/src/a11y/dismissable.test.ts +119 -119
  145. package/src/a11y/dismissable.ts +114 -114
  146. package/src/a11y.contract.test.ts +49 -49
  147. package/src/a11y.test.ts +59 -59
  148. package/src/a11y.ts +61 -61
  149. package/src/ai.test.ts +502 -502
  150. package/src/ai.ts +1391 -1391
  151. package/src/build-api.coverage.test.ts +633 -633
  152. package/src/build-api.ts +895 -895
  153. package/src/builtin-editors.grid.test.ts +85 -85
  154. package/src/cell-formatting.ts +169 -169
  155. package/src/cell-render.ts +469 -469
  156. package/src/chart-export.ts +201 -201
  157. package/src/chart-view.svelte.ts +36 -36
  158. package/src/chart.ts +2296 -2296
  159. package/src/collaboration.test.ts +104 -104
  160. package/src/collaboration.ts +167 -167
  161. package/src/column-groups.test.ts +48 -0
  162. package/src/column-groups.ts +8 -10
  163. package/src/column-id.ts +30 -0
  164. package/src/core.performance.test.ts +30 -30
  165. package/src/core.ts +1112 -1111
  166. package/src/createAutocomplete.svelte.ts +132 -132
  167. package/src/createCombobox.svelte.ts +191 -191
  168. package/src/createCountryInput.svelte.ts +157 -157
  169. package/src/createDropdownList.svelte.ts +168 -168
  170. package/src/createForm.svelte.ts +386 -386
  171. package/src/createGrid.svelte.ts +42 -42
  172. package/src/createGrid.test.ts +10 -10
  173. package/src/createGridState.svelte.ts +17 -17
  174. package/src/createListbox.svelte.ts +39 -7
  175. package/src/createMenu.svelte.ts +225 -225
  176. package/src/createPopoverSelect.svelte.ts +213 -213
  177. package/src/createSlider.svelte.ts +191 -191
  178. package/src/createTooltip.svelte.ts +144 -144
  179. package/src/createTree.svelte.ts +319 -319
  180. package/src/editing.test.ts +949 -859
  181. package/src/editing.ts +709 -675
  182. package/src/editor-contract.ts +157 -157
  183. package/src/export-data-api.test.ts +126 -126
  184. package/src/export-format.test.ts +107 -107
  185. package/src/export-format.ts +598 -598
  186. package/src/filter-operators.ts +157 -157
  187. package/src/filtering/excel-filters.test.ts +44 -1
  188. package/src/filtering/excel-filters.ts +91 -14
  189. package/src/flex-render.ts +3 -3
  190. package/src/form-field.ts +127 -127
  191. package/src/index.ts +715 -713
  192. package/src/js-scroller.svelte.ts +173 -173
  193. package/src/keyboard.test.ts +59 -59
  194. package/src/keyboard.ts +97 -97
  195. package/src/list-nav.test.ts +49 -49
  196. package/src/list-nav.ts +29 -29
  197. package/src/list-option.test.ts +56 -56
  198. package/src/list-option.ts +172 -172
  199. package/src/menus.test.ts +736 -700
  200. package/src/menus.ts +679 -652
  201. package/src/merge-objects.ts +48 -48
  202. package/src/overlays.test.ts +90 -90
  203. package/src/render-component.ts +28 -28
  204. package/src/row-resize.test.ts +100 -0
  205. package/src/row-resize.ts +36 -0
  206. package/src/scheduler-model.test.ts +562 -562
  207. package/src/scheduler-model.ts +872 -872
  208. package/src/selection.test.ts +754 -754
  209. package/src/selection.ts +600 -600
  210. package/src/server-data-source.test.ts +289 -289
  211. package/src/server-data-source.ts +413 -413
  212. package/src/sparkline.test.ts +68 -68
  213. package/src/sparkline.ts +169 -169
  214. package/src/spreadsheet.test.ts +489 -489
  215. package/src/spreadsheet.ts +312 -312
  216. package/src/static-functions.ts +11 -11
  217. package/src/subscribe.ts +38 -38
  218. package/src/svgrid-wrapper.types.ts +524 -524
  219. package/src/svgrid.api-extensions.test.ts +8 -3
  220. package/src/svgrid.behavior.test.ts +706 -706
  221. package/src/svgrid.cell-dom-ids.test.ts +96 -0
  222. package/src/svgrid.charting.test.ts +534 -534
  223. package/src/svgrid.conditional-stat-scope.test.ts +111 -0
  224. package/src/svgrid.features.test.ts +157 -157
  225. package/src/svgrid.filter-menu-listbox.svelte.test.ts +308 -0
  226. package/src/svgrid.filter-menu-scroll.test.ts +3 -1
  227. package/src/svgrid.group-header-ids.test.ts +121 -0
  228. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  229. package/src/svgrid.wrapper.test.ts +40 -40
  230. package/src/test-setup.ts +62 -62
  231. package/src/themes/index.ts +215 -215
  232. package/src/toast-store.svelte.ts +246 -246
  233. package/src/toast-store.test.ts +147 -147
  234. package/src/ui-buttons.test.ts +144 -144
  235. package/src/ui-inputs.test.ts +118 -118
  236. package/src/ui-range.test.ts +70 -70
  237. package/src/ui-selection.test.ts +155 -155
  238. package/src/ui-tier1.test.ts +142 -142
  239. package/src/virtual.test.ts +88 -88
  240. package/src/virtualization/column-virtualizer.test.ts +27 -27
  241. package/src/virtualization/column-virtualizer.ts +30 -30
  242. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  243. package/src/virtualization/types.ts +30 -30
  244. package/src/virtualization/virtualizer.test.ts +47 -47
  245. package/src/virtualization/virtualizer.ts +296 -296
  246. package/themes/ember.css +71 -71
  247. package/dist/cdn/GridMenus-BzxEYNtW.js +0 -421
  248. package/dist/cdn/GridMenus-Dr-nGEXn.js +0 -417
@@ -1,319 +1,319 @@
1
- /**
2
- * createTree - the HEADLESS core behind <SvTree>, in the same spirit as
3
- * `createSvGrid` for the grid: a runes-based state machine (expand/collapse,
4
- * single-select highlight, cascading tri-state checkboxes, full keyboard) with
5
- * **prop-getters** you spread onto YOUR OWN markup. The visible tree is
6
- * flattened into `rows` so roving-focus keyboard nav stays a flat index walk.
7
- *
8
- * ```svelte
9
- * <script lang="ts">
10
- * import { createTree } from '@svgrid/grid'
11
- * let selected = $state<string | null>(null)
12
- * const tree = createTree({ nodes: () => nodes, selected: () => selected, onSelect: (id) => (selected = id) })
13
- * </script>
14
- * <div {...tree.treeProps()}>
15
- * {#each tree.rows as row (row.node.id)}
16
- * <div {...tree.itemProps(row)}>{row.node.label}</div>
17
- * {/each}
18
- * </div>
19
- * ```
20
- *
21
- * The styled <SvTree> is just one renderer over this core. The cascade math
22
- * (`treeDescendantIds`, `treeCheckState`) is exported as pure functions.
23
- */
24
- export type TreeNode = {
25
- id: string
26
- label: string
27
- children?: TreeNode[]
28
- disabled?: boolean
29
- /** Marks a node whose children are loaded on demand (shows an expand arrow
30
- * even with no children yet; the renderer loads them on first expand). */
31
- lazy?: boolean
32
- }
33
-
34
- export type CheckState = 'checked' | 'indeterminate' | 'unchecked'
35
-
36
- /** A single visible (flattened) tree row. */
37
- export type TreeRow = {
38
- node: TreeNode
39
- depth: number
40
- hasChildren: boolean
41
- open: boolean
42
- parentId: string | null
43
- index: number
44
- }
45
-
46
- /** All descendant ids of `node` (pure, depth-first). */
47
- export function treeDescendantIds(node: TreeNode): string[] {
48
- const ids: string[] = []
49
- const walk = (n: TreeNode) => { for (const c of n.children ?? []) { ids.push(c.id); walk(c) } }
50
- walk(node)
51
- return ids
52
- }
53
-
54
- /** Tri-state check status of `node` given the set of checked ids (pure). */
55
- export function treeCheckState(node: TreeNode, checked: ReadonlySet<string>): CheckState {
56
- if (!node.children?.length) return checked.has(node.id) ? 'checked' : 'unchecked'
57
- const desc = treeDescendantIds(node)
58
- const on = desc.filter((id) => checked.has(id)).length
59
- if (on === 0 && !checked.has(node.id)) return 'unchecked'
60
- if (on === desc.length) return 'checked'
61
- return 'indeterminate'
62
- }
63
-
64
- function findNode(list: ReadonlyArray<TreeNode>, id: string): TreeNode | null {
65
- for (const n of list) {
66
- if (n.id === id) return n
67
- if (n.children) { const f = findNode(n.children, id); if (f) return f }
68
- }
69
- return null
70
- }
71
-
72
- /** Where a dragged node lands relative to the drop target. */
73
- export type TreeDropPosition = 'before' | 'after' | 'inside'
74
-
75
- /** True if `id` is inside `node`'s subtree (used to block invalid drops). */
76
- export function treeContains(node: TreeNode, id: string): boolean {
77
- return node.children?.some((c) => c.id === id || treeContains(c, id)) ?? false
78
- }
79
-
80
- /**
81
- * Return a NEW node tree with `dragId` moved before/after/inside `targetId`
82
- * (pure - the caller sets it back as the controlled `nodes`). No-ops on invalid
83
- * moves (self, or into own descendant).
84
- */
85
- export function moveTreeNode(
86
- nodes: ReadonlyArray<TreeNode>,
87
- dragId: string,
88
- targetId: string,
89
- position: TreeDropPosition,
90
- ): TreeNode[] {
91
- if (dragId === targetId) return [...nodes]
92
- const dragged = findNode(nodes, dragId)
93
- if (!dragged) return [...nodes]
94
- if (dragId === targetId || treeContains(dragged, targetId)) return [...nodes]
95
-
96
- const without = (list: ReadonlyArray<TreeNode>): TreeNode[] => {
97
- const out: TreeNode[] = []
98
- for (const n of list) {
99
- if (n.id === dragId) continue
100
- out.push(n.children ? { ...n, children: without(n.children) } : n)
101
- }
102
- return out
103
- }
104
- const insert = (list: ReadonlyArray<TreeNode>): TreeNode[] => {
105
- const out: TreeNode[] = []
106
- for (const n of list) {
107
- if (n.id === targetId) {
108
- if (position === 'before') { out.push(dragged, n) }
109
- else if (position === 'after') { out.push(n, dragged) }
110
- else out.push({ ...n, children: [...(n.children ?? []), dragged] })
111
- } else if (n.children) {
112
- out.push({ ...n, children: insert(n.children) })
113
- } else out.push(n)
114
- }
115
- return out
116
- }
117
- return insert(without(nodes))
118
- }
119
-
120
- /** Sort siblings (recursively) by a comparator (pure). */
121
- export function sortTreeNodes(
122
- nodes: ReadonlyArray<TreeNode>,
123
- compare: (a: TreeNode, b: TreeNode) => number,
124
- ): TreeNode[] {
125
- return [...nodes]
126
- .sort(compare)
127
- .map((n) => (n.children ? { ...n, children: sortTreeNodes(n.children, compare) } : n))
128
- }
129
-
130
- /** Reactive inputs are passed as getters so the core tracks live prop changes. */
131
- export type TreeConfig = {
132
- nodes: () => ReadonlyArray<TreeNode>
133
- selected?: () => string | null
134
- onSelect?: (id: string) => void
135
- /** Seed the internally-managed expanded set (read once). */
136
- expandedIds?: () => string[] | undefined
137
- onToggle?: (id: string, expanded: boolean) => void
138
- checkable?: () => boolean
139
- checked?: () => string[]
140
- onCheck?: (ids: string[]) => void
141
- ariaLabel?: () => string | undefined
142
- /** Text direction; under `'rtl'` the Left/Right expand/collapse keys swap. */
143
- dir?: () => import('./editor-contract').EditorDir | undefined
144
- /** Filter query: show only matching nodes + their ancestors, auto-expanded. */
145
- filter?: () => string | undefined
146
- }
147
-
148
- export function createTree(config: TreeConfig) {
149
- const nodes = () => config.nodes()
150
- const checkable = () => config.checkable?.() ?? false
151
- const selected = () => config.selected?.() ?? null
152
-
153
- // Internal expanded set, seeded from the prop once (then self-managed).
154
- let expanded = $state<Set<string>>(new Set())
155
- let seeded = false
156
- $effect(() => {
157
- if (!seeded) { seeded = true; const ids = config.expandedIds?.(); if (ids) expanded = new Set(ids) }
158
- })
159
-
160
- // When filtering, precompute which nodes to show (a match, or an ancestor of a
161
- // match) and which to force-open so matches are revealed.
162
- const filterInfo = $derived.by(() => {
163
- const q = config.filter?.()?.trim().toLowerCase()
164
- if (!q) return null
165
- const visible = new Set<string>()
166
- const forceOpen = new Set<string>()
167
- const walk = (node: TreeNode): boolean => {
168
- let childMatch = false
169
- for (const c of node.children ?? []) childMatch = walk(c) || childMatch
170
- const selfMatch = node.label.toLowerCase().includes(q)
171
- if (selfMatch || childMatch) { visible.add(node.id); if (childMatch) forceOpen.add(node.id) }
172
- return selfMatch || childMatch
173
- }
174
- for (const n of nodes()) walk(n)
175
- return { visible, forceOpen }
176
- })
177
-
178
- const rows = $derived.by<TreeRow[]>(() => {
179
- const out: TreeRow[] = []
180
- const fi = filterInfo
181
- const walk = (list: ReadonlyArray<TreeNode>, depth: number, parentId: string | null) => {
182
- for (const node of list) {
183
- if (fi && !fi.visible.has(node.id)) continue
184
- const hasChildren = !!node.children?.length || !!node.lazy
185
- const open = fi ? fi.forceOpen.has(node.id) || expanded.has(node.id) : expanded.has(node.id)
186
- out.push({ node, depth, hasChildren, open, parentId, index: out.length })
187
- if (open && node.children?.length) walk(node.children, depth + 1, node.id)
188
- }
189
- }
190
- walk(nodes(), 0, null)
191
- return out
192
- })
193
-
194
- let active = $state(0)
195
- $effect(() => { if (active >= rows.length) active = Math.max(0, rows.length - 1) })
196
-
197
- const checkedSet = $derived(new Set(config.checked?.() ?? []))
198
-
199
- // Roving focus target; `focusTick` bumps only on keyboard moves.
200
- let focusTick = $state(0)
201
- function setActive(i: number, focus = false) { active = i; if (focus) focusTick++ }
202
-
203
- const checkStateOf = (node: TreeNode): CheckState => treeCheckState(node, checkedSet)
204
- const isExpanded = (id: string) => expanded.has(id)
205
- const isSelected = (id: string) => id === selected()
206
-
207
- function toggleExpand(node: TreeNode) {
208
- // Lazy nodes have no children *yet* - they must still be expandable so the
209
- // toggle fires onToggle and the host can fetch children on demand.
210
- if (!node.children?.length && !node.lazy) return
211
- const next = new Set(expanded)
212
- const willOpen = !next.has(node.id)
213
- willOpen ? next.add(node.id) : next.delete(node.id)
214
- expanded = next
215
- config.onToggle?.(node.id, willOpen)
216
- }
217
- const toggle = (id: string) => { const n = findNode(nodes(), id); if (n) toggleExpand(n) }
218
-
219
- function select(node: TreeNode) {
220
- if (node.disabled) return
221
- config.onSelect?.(node.id)
222
- }
223
- const selectId = (id: string) => { const n = findNode(nodes(), id); if (n) select(n) }
224
-
225
- function toggleCheck(node: TreeNode) {
226
- if (node.disabled) return
227
- const next = new Set(checkedSet)
228
- const ids = [node.id, ...treeDescendantIds(node)]
229
- const currentlyOn = checkStateOf(node) === 'checked'
230
- for (const id of ids) currentlyOn ? next.delete(id) : next.add(id)
231
- config.onCheck?.([...next])
232
- }
233
-
234
- // Step from `from` towards `dir` (+1/-1), skipping disabled rows, so roving
235
- // focus never lands on a node with zero semantic affordance for AT users.
236
- // Falls back to `from` if every row in that direction is disabled.
237
- function stepEnabled(from: number, dir: 1 | -1): number {
238
- let i = from
239
- while (i + dir >= 0 && i + dir <= rows.length - 1) {
240
- i += dir
241
- if (!rows[i]?.node.disabled) return i
242
- }
243
- return from
244
- }
245
-
246
- function onKeydown(e: KeyboardEvent) {
247
- const item = rows[active]
248
- if (!item) return
249
- // "expand" opens the node or steps into the first child; "collapse" closes it
250
- // or jumps to the parent. Under RTL the physical Left/Right keys are swapped.
251
- const expand = () => {
252
- if (item.hasChildren && !item.open) toggleExpand(item.node)
253
- else if (item.hasChildren && item.open) setActive(Math.min(active + 1, rows.length - 1), true)
254
- }
255
- const collapse = () => {
256
- if (item.hasChildren && item.open) toggleExpand(item.node)
257
- else if (item.parentId) { const pi = rows.findIndex((r) => r.node.id === item.parentId); if (pi >= 0) setActive(pi, true) }
258
- }
259
- const rtl = config.dir?.() === 'rtl'
260
- switch (e.key) {
261
- case 'ArrowDown': e.preventDefault(); setActive(stepEnabled(active, 1), true); break
262
- case 'ArrowUp': e.preventDefault(); setActive(stepEnabled(active, -1), true); break
263
- case 'ArrowRight': e.preventDefault(); (rtl ? collapse : expand)(); break
264
- case 'ArrowLeft': e.preventDefault(); (rtl ? expand : collapse)(); break
265
- case 'Enter': e.preventDefault(); select(item.node); break
266
- case ' ': e.preventDefault(); checkable() ? toggleCheck(item.node) : select(item.node); break
267
- case 'Home': e.preventDefault(); setActive(0, true); break
268
- case 'End': e.preventDefault(); setActive(rows.length - 1, true); break
269
- }
270
- }
271
-
272
- return {
273
- /** Internally-managed set of expanded node ids. */
274
- get expanded() { return expanded },
275
- /** Selected node id (single-select highlight). */
276
- get selected() { return selected() },
277
- /** Flattened list of visible rows. */
278
- get rows() { return rows },
279
- /** Roving-focus row index. */
280
- get activeIndex() { return active },
281
- /** Monotonic counter; changes only on keyboard navigation. */
282
- get focusTick() { return focusTick },
283
- isExpanded,
284
- isSelected,
285
- checkStateOf,
286
- toggle,
287
- toggleExpand,
288
- select: selectId,
289
- toggleCheck,
290
- setActive,
291
- onKeydown,
292
- /** Spread onto the tree container element. */
293
- treeProps: () => ({
294
- role: 'tree' as const,
295
- 'aria-label': config.ariaLabel?.(),
296
- 'aria-multiselectable': checkable(),
297
- }),
298
- /** Spread onto the treeitem element for a flattened `row`. */
299
- itemProps: (row: TreeRow) => {
300
- const cs = checkable() ? checkStateOf(row.node) : 'unchecked'
301
- const ariaChecked: 'true' | 'mixed' | 'false' | undefined =
302
- checkable() ? (cs === 'checked' ? 'true' : cs === 'indeterminate' ? 'mixed' : 'false') : undefined
303
- return {
304
- role: 'treeitem' as const,
305
- 'aria-level': row.depth + 1,
306
- 'aria-selected': row.node.id === selected(),
307
- 'aria-expanded': row.hasChildren ? row.open : undefined,
308
- 'aria-checked': ariaChecked,
309
- 'aria-disabled': row.node.disabled || undefined,
310
- 'data-row': row.index,
311
- tabindex: row.index === active ? 0 : -1,
312
- onclick: () => { setActive(row.index); select(row.node) },
313
- onkeydown: onKeydown,
314
- }
315
- },
316
- }
317
- }
318
-
319
- export type Tree = ReturnType<typeof createTree>
1
+ /**
2
+ * createTree - the HEADLESS core behind <SvTree>, in the same spirit as
3
+ * `createSvGrid` for the grid: a runes-based state machine (expand/collapse,
4
+ * single-select highlight, cascading tri-state checkboxes, full keyboard) with
5
+ * **prop-getters** you spread onto YOUR OWN markup. The visible tree is
6
+ * flattened into `rows` so roving-focus keyboard nav stays a flat index walk.
7
+ *
8
+ * ```svelte
9
+ * <script lang="ts">
10
+ * import { createTree } from '@svgrid/grid'
11
+ * let selected = $state<string | null>(null)
12
+ * const tree = createTree({ nodes: () => nodes, selected: () => selected, onSelect: (id) => (selected = id) })
13
+ * </script>
14
+ * <div {...tree.treeProps()}>
15
+ * {#each tree.rows as row (row.node.id)}
16
+ * <div {...tree.itemProps(row)}>{row.node.label}</div>
17
+ * {/each}
18
+ * </div>
19
+ * ```
20
+ *
21
+ * The styled <SvTree> is just one renderer over this core. The cascade math
22
+ * (`treeDescendantIds`, `treeCheckState`) is exported as pure functions.
23
+ */
24
+ export type TreeNode = {
25
+ id: string
26
+ label: string
27
+ children?: TreeNode[]
28
+ disabled?: boolean
29
+ /** Marks a node whose children are loaded on demand (shows an expand arrow
30
+ * even with no children yet; the renderer loads them on first expand). */
31
+ lazy?: boolean
32
+ }
33
+
34
+ export type CheckState = 'checked' | 'indeterminate' | 'unchecked'
35
+
36
+ /** A single visible (flattened) tree row. */
37
+ export type TreeRow = {
38
+ node: TreeNode
39
+ depth: number
40
+ hasChildren: boolean
41
+ open: boolean
42
+ parentId: string | null
43
+ index: number
44
+ }
45
+
46
+ /** All descendant ids of `node` (pure, depth-first). */
47
+ export function treeDescendantIds(node: TreeNode): string[] {
48
+ const ids: string[] = []
49
+ const walk = (n: TreeNode) => { for (const c of n.children ?? []) { ids.push(c.id); walk(c) } }
50
+ walk(node)
51
+ return ids
52
+ }
53
+
54
+ /** Tri-state check status of `node` given the set of checked ids (pure). */
55
+ export function treeCheckState(node: TreeNode, checked: ReadonlySet<string>): CheckState {
56
+ if (!node.children?.length) return checked.has(node.id) ? 'checked' : 'unchecked'
57
+ const desc = treeDescendantIds(node)
58
+ const on = desc.filter((id) => checked.has(id)).length
59
+ if (on === 0 && !checked.has(node.id)) return 'unchecked'
60
+ if (on === desc.length) return 'checked'
61
+ return 'indeterminate'
62
+ }
63
+
64
+ function findNode(list: ReadonlyArray<TreeNode>, id: string): TreeNode | null {
65
+ for (const n of list) {
66
+ if (n.id === id) return n
67
+ if (n.children) { const f = findNode(n.children, id); if (f) return f }
68
+ }
69
+ return null
70
+ }
71
+
72
+ /** Where a dragged node lands relative to the drop target. */
73
+ export type TreeDropPosition = 'before' | 'after' | 'inside'
74
+
75
+ /** True if `id` is inside `node`'s subtree (used to block invalid drops). */
76
+ export function treeContains(node: TreeNode, id: string): boolean {
77
+ return node.children?.some((c) => c.id === id || treeContains(c, id)) ?? false
78
+ }
79
+
80
+ /**
81
+ * Return a NEW node tree with `dragId` moved before/after/inside `targetId`
82
+ * (pure - the caller sets it back as the controlled `nodes`). No-ops on invalid
83
+ * moves (self, or into own descendant).
84
+ */
85
+ export function moveTreeNode(
86
+ nodes: ReadonlyArray<TreeNode>,
87
+ dragId: string,
88
+ targetId: string,
89
+ position: TreeDropPosition,
90
+ ): TreeNode[] {
91
+ if (dragId === targetId) return [...nodes]
92
+ const dragged = findNode(nodes, dragId)
93
+ if (!dragged) return [...nodes]
94
+ if (dragId === targetId || treeContains(dragged, targetId)) return [...nodes]
95
+
96
+ const without = (list: ReadonlyArray<TreeNode>): TreeNode[] => {
97
+ const out: TreeNode[] = []
98
+ for (const n of list) {
99
+ if (n.id === dragId) continue
100
+ out.push(n.children ? { ...n, children: without(n.children) } : n)
101
+ }
102
+ return out
103
+ }
104
+ const insert = (list: ReadonlyArray<TreeNode>): TreeNode[] => {
105
+ const out: TreeNode[] = []
106
+ for (const n of list) {
107
+ if (n.id === targetId) {
108
+ if (position === 'before') { out.push(dragged, n) }
109
+ else if (position === 'after') { out.push(n, dragged) }
110
+ else out.push({ ...n, children: [...(n.children ?? []), dragged] })
111
+ } else if (n.children) {
112
+ out.push({ ...n, children: insert(n.children) })
113
+ } else out.push(n)
114
+ }
115
+ return out
116
+ }
117
+ return insert(without(nodes))
118
+ }
119
+
120
+ /** Sort siblings (recursively) by a comparator (pure). */
121
+ export function sortTreeNodes(
122
+ nodes: ReadonlyArray<TreeNode>,
123
+ compare: (a: TreeNode, b: TreeNode) => number,
124
+ ): TreeNode[] {
125
+ return [...nodes]
126
+ .sort(compare)
127
+ .map((n) => (n.children ? { ...n, children: sortTreeNodes(n.children, compare) } : n))
128
+ }
129
+
130
+ /** Reactive inputs are passed as getters so the core tracks live prop changes. */
131
+ export type TreeConfig = {
132
+ nodes: () => ReadonlyArray<TreeNode>
133
+ selected?: () => string | null
134
+ onSelect?: (id: string) => void
135
+ /** Seed the internally-managed expanded set (read once). */
136
+ expandedIds?: () => string[] | undefined
137
+ onToggle?: (id: string, expanded: boolean) => void
138
+ checkable?: () => boolean
139
+ checked?: () => string[]
140
+ onCheck?: (ids: string[]) => void
141
+ ariaLabel?: () => string | undefined
142
+ /** Text direction; under `'rtl'` the Left/Right expand/collapse keys swap. */
143
+ dir?: () => import('./editor-contract').EditorDir | undefined
144
+ /** Filter query: show only matching nodes + their ancestors, auto-expanded. */
145
+ filter?: () => string | undefined
146
+ }
147
+
148
+ export function createTree(config: TreeConfig) {
149
+ const nodes = () => config.nodes()
150
+ const checkable = () => config.checkable?.() ?? false
151
+ const selected = () => config.selected?.() ?? null
152
+
153
+ // Internal expanded set, seeded from the prop once (then self-managed).
154
+ let expanded = $state<Set<string>>(new Set())
155
+ let seeded = false
156
+ $effect(() => {
157
+ if (!seeded) { seeded = true; const ids = config.expandedIds?.(); if (ids) expanded = new Set(ids) }
158
+ })
159
+
160
+ // When filtering, precompute which nodes to show (a match, or an ancestor of a
161
+ // match) and which to force-open so matches are revealed.
162
+ const filterInfo = $derived.by(() => {
163
+ const q = config.filter?.()?.trim().toLowerCase()
164
+ if (!q) return null
165
+ const visible = new Set<string>()
166
+ const forceOpen = new Set<string>()
167
+ const walk = (node: TreeNode): boolean => {
168
+ let childMatch = false
169
+ for (const c of node.children ?? []) childMatch = walk(c) || childMatch
170
+ const selfMatch = node.label.toLowerCase().includes(q)
171
+ if (selfMatch || childMatch) { visible.add(node.id); if (childMatch) forceOpen.add(node.id) }
172
+ return selfMatch || childMatch
173
+ }
174
+ for (const n of nodes()) walk(n)
175
+ return { visible, forceOpen }
176
+ })
177
+
178
+ const rows = $derived.by<TreeRow[]>(() => {
179
+ const out: TreeRow[] = []
180
+ const fi = filterInfo
181
+ const walk = (list: ReadonlyArray<TreeNode>, depth: number, parentId: string | null) => {
182
+ for (const node of list) {
183
+ if (fi && !fi.visible.has(node.id)) continue
184
+ const hasChildren = !!node.children?.length || !!node.lazy
185
+ const open = fi ? fi.forceOpen.has(node.id) || expanded.has(node.id) : expanded.has(node.id)
186
+ out.push({ node, depth, hasChildren, open, parentId, index: out.length })
187
+ if (open && node.children?.length) walk(node.children, depth + 1, node.id)
188
+ }
189
+ }
190
+ walk(nodes(), 0, null)
191
+ return out
192
+ })
193
+
194
+ let active = $state(0)
195
+ $effect(() => { if (active >= rows.length) active = Math.max(0, rows.length - 1) })
196
+
197
+ const checkedSet = $derived(new Set(config.checked?.() ?? []))
198
+
199
+ // Roving focus target; `focusTick` bumps only on keyboard moves.
200
+ let focusTick = $state(0)
201
+ function setActive(i: number, focus = false) { active = i; if (focus) focusTick++ }
202
+
203
+ const checkStateOf = (node: TreeNode): CheckState => treeCheckState(node, checkedSet)
204
+ const isExpanded = (id: string) => expanded.has(id)
205
+ const isSelected = (id: string) => id === selected()
206
+
207
+ function toggleExpand(node: TreeNode) {
208
+ // Lazy nodes have no children *yet* - they must still be expandable so the
209
+ // toggle fires onToggle and the host can fetch children on demand.
210
+ if (!node.children?.length && !node.lazy) return
211
+ const next = new Set(expanded)
212
+ const willOpen = !next.has(node.id)
213
+ willOpen ? next.add(node.id) : next.delete(node.id)
214
+ expanded = next
215
+ config.onToggle?.(node.id, willOpen)
216
+ }
217
+ const toggle = (id: string) => { const n = findNode(nodes(), id); if (n) toggleExpand(n) }
218
+
219
+ function select(node: TreeNode) {
220
+ if (node.disabled) return
221
+ config.onSelect?.(node.id)
222
+ }
223
+ const selectId = (id: string) => { const n = findNode(nodes(), id); if (n) select(n) }
224
+
225
+ function toggleCheck(node: TreeNode) {
226
+ if (node.disabled) return
227
+ const next = new Set(checkedSet)
228
+ const ids = [node.id, ...treeDescendantIds(node)]
229
+ const currentlyOn = checkStateOf(node) === 'checked'
230
+ for (const id of ids) currentlyOn ? next.delete(id) : next.add(id)
231
+ config.onCheck?.([...next])
232
+ }
233
+
234
+ // Step from `from` towards `dir` (+1/-1), skipping disabled rows, so roving
235
+ // focus never lands on a node with zero semantic affordance for AT users.
236
+ // Falls back to `from` if every row in that direction is disabled.
237
+ function stepEnabled(from: number, dir: 1 | -1): number {
238
+ let i = from
239
+ while (i + dir >= 0 && i + dir <= rows.length - 1) {
240
+ i += dir
241
+ if (!rows[i]?.node.disabled) return i
242
+ }
243
+ return from
244
+ }
245
+
246
+ function onKeydown(e: KeyboardEvent) {
247
+ const item = rows[active]
248
+ if (!item) return
249
+ // "expand" opens the node or steps into the first child; "collapse" closes it
250
+ // or jumps to the parent. Under RTL the physical Left/Right keys are swapped.
251
+ const expand = () => {
252
+ if (item.hasChildren && !item.open) toggleExpand(item.node)
253
+ else if (item.hasChildren && item.open) setActive(Math.min(active + 1, rows.length - 1), true)
254
+ }
255
+ const collapse = () => {
256
+ if (item.hasChildren && item.open) toggleExpand(item.node)
257
+ else if (item.parentId) { const pi = rows.findIndex((r) => r.node.id === item.parentId); if (pi >= 0) setActive(pi, true) }
258
+ }
259
+ const rtl = config.dir?.() === 'rtl'
260
+ switch (e.key) {
261
+ case 'ArrowDown': e.preventDefault(); setActive(stepEnabled(active, 1), true); break
262
+ case 'ArrowUp': e.preventDefault(); setActive(stepEnabled(active, -1), true); break
263
+ case 'ArrowRight': e.preventDefault(); (rtl ? collapse : expand)(); break
264
+ case 'ArrowLeft': e.preventDefault(); (rtl ? expand : collapse)(); break
265
+ case 'Enter': e.preventDefault(); select(item.node); break
266
+ case ' ': e.preventDefault(); checkable() ? toggleCheck(item.node) : select(item.node); break
267
+ case 'Home': e.preventDefault(); setActive(0, true); break
268
+ case 'End': e.preventDefault(); setActive(rows.length - 1, true); break
269
+ }
270
+ }
271
+
272
+ return {
273
+ /** Internally-managed set of expanded node ids. */
274
+ get expanded() { return expanded },
275
+ /** Selected node id (single-select highlight). */
276
+ get selected() { return selected() },
277
+ /** Flattened list of visible rows. */
278
+ get rows() { return rows },
279
+ /** Roving-focus row index. */
280
+ get activeIndex() { return active },
281
+ /** Monotonic counter; changes only on keyboard navigation. */
282
+ get focusTick() { return focusTick },
283
+ isExpanded,
284
+ isSelected,
285
+ checkStateOf,
286
+ toggle,
287
+ toggleExpand,
288
+ select: selectId,
289
+ toggleCheck,
290
+ setActive,
291
+ onKeydown,
292
+ /** Spread onto the tree container element. */
293
+ treeProps: () => ({
294
+ role: 'tree' as const,
295
+ 'aria-label': config.ariaLabel?.(),
296
+ 'aria-multiselectable': checkable(),
297
+ }),
298
+ /** Spread onto the treeitem element for a flattened `row`. */
299
+ itemProps: (row: TreeRow) => {
300
+ const cs = checkable() ? checkStateOf(row.node) : 'unchecked'
301
+ const ariaChecked: 'true' | 'mixed' | 'false' | undefined =
302
+ checkable() ? (cs === 'checked' ? 'true' : cs === 'indeterminate' ? 'mixed' : 'false') : undefined
303
+ return {
304
+ role: 'treeitem' as const,
305
+ 'aria-level': row.depth + 1,
306
+ 'aria-selected': row.node.id === selected(),
307
+ 'aria-expanded': row.hasChildren ? row.open : undefined,
308
+ 'aria-checked': ariaChecked,
309
+ 'aria-disabled': row.node.disabled || undefined,
310
+ 'data-row': row.index,
311
+ tabindex: row.index === active ? 0 : -1,
312
+ onclick: () => { setActive(row.index); select(row.node) },
313
+ onkeydown: onKeydown,
314
+ }
315
+ },
316
+ }
317
+ }
318
+
319
+ export type Tree = ReturnType<typeof createTree>