@svgrid/grid 2.2.31 → 2.2.33

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 (196) hide show
  1. package/README.md +1 -1
  2. package/dist/DockNodeView.svelte +1 -1
  3. package/dist/GridFooter.svelte +0 -40
  4. package/dist/GridMenus.svelte +0 -77
  5. package/dist/SvAvatar.svelte +1 -1
  6. package/dist/SvCheckBox.svelte +2 -1
  7. package/dist/SvCircularProgress.svelte +1 -1
  8. package/dist/SvDateRangeInput.svelte +1 -1
  9. package/dist/SvGrid.controller.svelte.d.ts +20 -0
  10. package/dist/SvGrid.controller.svelte.js +358 -18
  11. package/dist/SvGrid.css +103 -3
  12. package/dist/SvGrid.svelte +164 -35
  13. package/dist/SvGrid.types.d.ts +81 -0
  14. package/dist/SvGridChart.svelte +2 -10
  15. package/dist/SvGridDropdown.svelte +38 -6
  16. package/dist/SvGridDropdown.svelte.d.ts +4 -0
  17. package/dist/SvGridSelect.svelte +12 -5
  18. package/dist/SvGridSelect.svelte.d.ts +6 -0
  19. package/dist/SvGroupCell.svelte +9 -2
  20. package/dist/SvKbd.svelte +1 -1
  21. package/dist/SvList.svelte +1 -1
  22. package/dist/SvMenubar.svelte +1 -1
  23. package/dist/SvMultiSelect.svelte +12 -4
  24. package/dist/SvMultiSelect.svelte.d.ts +6 -0
  25. package/dist/SvOtpInput.svelte +1 -1
  26. package/dist/SvPasswordInput.svelte +1 -1
  27. package/dist/SvProgress.svelte +1 -1
  28. package/dist/SvRating.svelte +1 -1
  29. package/dist/SvResult.svelte +1 -1
  30. package/dist/SvRichText.svelte +35 -16
  31. package/dist/SvRichText.svelte.d.ts +9 -0
  32. package/dist/SvSwitchButton.svelte +2 -1
  33. package/dist/SvToaster.svelte +1 -1
  34. package/dist/SvTour.svelte +4 -0
  35. package/dist/SvTree.svelte +9 -2
  36. package/dist/SvTree.svelte.d.ts +5 -0
  37. package/dist/SvTreeSelect.svelte +6 -4
  38. package/dist/build-api.js +16 -0
  39. package/dist/cdn/{GridMenus-Ds3OpzZT.js → GridMenus-B7XhhqdN.js} +34 -36
  40. package/dist/cdn/{GridMenus-Da_p7SKS.js → GridMenus-BxmIvCJh.js} +35 -37
  41. package/dist/cdn/{SvDateTimePicker-DB0aIEk8.js → SvDateTimePicker-ClqtVIj3.js} +2 -1
  42. package/dist/cdn/{SvDateTimePicker-B8GopjhC.js → SvDateTimePicker-o94wfSUa.js} +3 -2
  43. package/dist/cdn/{SvGridChart-BcLCYvw9.js → SvGridChart-Cr_90pPD.js} +1 -1
  44. package/dist/cdn/{SvGridChartPanel-DvjwxLvK.js → SvGridChartPanel-ICbxiUvK.js} +4 -4
  45. package/dist/cdn/{SvGridChartView-PwqI4s02.js → SvGridChartView-Bx42PMvf.js} +2 -2
  46. package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-CUOdm7YA.js} +93 -88
  47. package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D0VdjeR8.js} +129 -123
  48. package/dist/cdn/{disclose-version-DoD9AFD_.js → disclose-version-DljhZohK.js} +45 -45
  49. package/dist/cdn/{src-DYDCiC0D.js → src-erjBRYfX.js} +5952 -5615
  50. package/dist/cdn/{src-BLJfdyL0.js → src-ktbQVOk-.js} +4190 -3851
  51. package/dist/cdn/svgrid.js +9 -9
  52. package/dist/cdn/svgrid.svelte-external.js +8 -8
  53. package/dist/cell-render.d.ts +1 -0
  54. package/dist/cell-render.js +115 -1
  55. package/dist/cell-values.js +5 -0
  56. package/dist/chart.d.ts +2 -0
  57. package/dist/chart.js +1 -4
  58. package/dist/core.d.ts +80 -9
  59. package/dist/core.js +89 -0
  60. package/dist/createMenu.svelte.js +0 -1
  61. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  62. package/dist/editing.js +1 -1
  63. package/dist/editor-contract.d.ts +12 -5
  64. package/dist/editor-registry.d.ts +18 -7
  65. package/dist/editor-registry.js +10 -1
  66. package/dist/export-format.js +3 -0
  67. package/dist/group-display.d.ts +39 -0
  68. package/dist/group-display.js +52 -0
  69. package/dist/index.d.ts +2 -2
  70. package/dist/index.js +2 -2
  71. package/dist/keyboard-handlers.js +16 -0
  72. package/dist/recurrence.js +3 -2
  73. package/dist/selection.js +0 -1
  74. package/dist/svgrid-wrapper.types.d.ts +8 -2
  75. package/dist/themes/index.d.ts +10 -0
  76. package/dist/themes/index.js +26 -0
  77. package/package.json +3 -2
  78. package/src/DockNodeView.svelte +1 -1
  79. package/src/GridFooter.svelte +0 -40
  80. package/src/GridMenus.svelte +0 -77
  81. package/src/SvAccordion.test.ts +5 -5
  82. package/src/SvAvatar.svelte +1 -1
  83. package/src/SvButtonGroup.test.ts +6 -6
  84. package/src/SvCalendar.test.ts +1 -1
  85. package/src/SvCheckBox.svelte +2 -1
  86. package/src/SvCircularProgress.svelte +1 -1
  87. package/src/SvDateRangeInput.svelte +1 -1
  88. package/src/SvFileUpload.test.ts +1 -1
  89. package/src/SvGrid.controller.svelte.ts +367 -22
  90. package/src/SvGrid.css +103 -3
  91. package/src/SvGrid.svelte +164 -35
  92. package/src/SvGrid.types.ts +81 -1
  93. package/src/SvGridChart.svelte +2 -10
  94. package/src/SvGridDropdown.svelte +38 -6
  95. package/src/SvGridSelect.svelte +12 -5
  96. package/src/SvGroupCell.svelte +9 -2
  97. package/src/SvKbd.svelte +1 -1
  98. package/src/SvList.svelte +1 -1
  99. package/src/SvMenubar.svelte +1 -1
  100. package/src/SvMultiSelect.svelte +12 -4
  101. package/src/SvOtpInput.svelte +1 -1
  102. package/src/SvPasswordInput.svelte +1 -1
  103. package/src/SvProgress.svelte +1 -1
  104. package/src/SvRating.svelte +1 -1
  105. package/src/SvResult.svelte +1 -1
  106. package/src/SvRichText.svelte +35 -16
  107. package/src/SvSwitchButton.svelte +2 -1
  108. package/src/SvToaster.svelte +1 -1
  109. package/src/SvTour.svelte +4 -0
  110. package/src/SvTree.svelte +9 -2
  111. package/src/SvTreeSelect.svelte +6 -4
  112. package/src/ai.test.ts +1 -1
  113. package/src/build-api.ts +15 -105
  114. package/src/builtin-editors.test.ts +3 -1
  115. package/src/cell-formatting.test.ts +1 -1
  116. package/src/cell-render.test.ts +27 -3
  117. package/src/cell-render.ts +122 -95
  118. package/src/cell-values.ts +4 -0
  119. package/src/chart-export.test.ts +2 -2
  120. package/src/chart.ts +1 -2
  121. package/src/clipboard.test.ts +0 -2
  122. package/src/clipboard.ts +0 -102
  123. package/src/columns.ts +0 -115
  124. package/src/core.ts +157 -8
  125. package/src/createMenu.svelte.ts +0 -1
  126. package/src/editing.test.ts +1 -1
  127. package/src/editing.ts +2 -105
  128. package/src/editor-contract.ts +12 -5
  129. package/src/editor-registry.grid.test.ts +48 -2
  130. package/src/editor-registry.test.ts +3 -1
  131. package/src/editor-registry.ts +23 -7
  132. package/src/export-format.ts +3 -0
  133. package/src/facet-buckets.test.ts +33 -33
  134. package/src/features.ts +0 -90
  135. package/src/group-display.test.ts +91 -1
  136. package/src/group-display.ts +83 -0
  137. package/src/index.ts +9 -0
  138. package/src/keyboard-handlers.coverage.test.ts +1 -1
  139. package/src/keyboard-handlers.ts +17 -120
  140. package/src/list-power.test.ts +2 -2
  141. package/src/menus.test.ts +4 -5
  142. package/src/menus.ts +0 -112
  143. package/src/named-views.coverage.test.ts +3 -3
  144. package/src/new-features.test.ts +3 -1
  145. package/src/recurrence.ts +3 -2
  146. package/src/row-drag.test.ts +1 -1
  147. package/src/row-resize.test.ts +41 -42
  148. package/src/scheduler-model.ts +1 -1
  149. package/src/scroll-sync.test.ts +4 -7
  150. package/src/scroll-sync.ts +0 -122
  151. package/src/selection.ts +0 -109
  152. package/src/server-data-source.coverage.test.ts +1 -1
  153. package/src/server-group-model.test.ts +8 -8
  154. package/src/spreadsheet.test.ts +0 -1
  155. package/src/summaries.ts +0 -112
  156. package/src/svgrid-wrapper.types.ts +11 -9
  157. package/src/svgrid.async-editor-options.test.ts +271 -0
  158. package/src/svgrid.auto-row-height.test.ts +204 -0
  159. package/src/svgrid.behavior.test.ts +0 -1
  160. package/src/svgrid.charting.test.ts +4 -4
  161. package/src/svgrid.column-reorder.test.ts +0 -8
  162. package/src/svgrid.comments-autocomplete.test.ts +8 -2
  163. package/src/svgrid.context-menu.test.ts +25 -9
  164. package/src/svgrid.filter-depth.test.ts +163 -0
  165. package/src/svgrid.filter-menu-listbox.svelte.test.ts +16 -2
  166. package/src/svgrid.filter-menu-scroll.test.ts +10 -2
  167. package/src/svgrid.grand-total.test.ts +188 -0
  168. package/src/svgrid.group-display-mode.test.ts +171 -0
  169. package/src/svgrid.group-footers.test.ts +121 -0
  170. package/src/svgrid.group-pagination.test.ts +153 -0
  171. package/src/svgrid.tree-data.test.ts +186 -0
  172. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -0
  173. package/src/themes/index.ts +42 -0
  174. package/src/tree-edit.test.ts +4 -4
  175. package/src/tree-row-model.test.ts +168 -0
  176. package/src/ui-localization.test.ts +113 -0
  177. package/themes/ag-alpine.css +22 -0
  178. package/themes/antd.css +22 -0
  179. package/themes/atlassian.css +22 -0
  180. package/themes/bootstrap.css +22 -0
  181. package/themes/carbon.css +22 -0
  182. package/themes/catppuccin.css +22 -0
  183. package/themes/dracula.css +22 -0
  184. package/themes/ember.css +22 -0
  185. package/themes/excel.css +22 -0
  186. package/themes/fluent.css +22 -0
  187. package/themes/github.css +22 -0
  188. package/themes/linear.css +22 -0
  189. package/themes/material.css +22 -0
  190. package/themes/nord.css +22 -0
  191. package/themes/notion.css +22 -0
  192. package/themes/salesforce.css +22 -0
  193. package/themes/sap.css +22 -0
  194. package/themes/shadcn.css +22 -0
  195. package/themes/tailwind.css +22 -0
  196. package/themes/vercel.css +22 -0
@@ -0,0 +1,186 @@
1
+ /**
2
+ * DOM: `treeData` renders a client-side hierarchy (#26).
3
+ *
4
+ * The point of the design is that tree rows stay REAL data rows: they render
5
+ * their own cells, so editing/formatting/selection all keep working, and only
6
+ * gain an expander plus indentation in the tree column. Grouping's banners are
7
+ * synthetic full-width rows - a tree row must never be rendered like one.
8
+ */
9
+ import { afterEach, describe, expect, it } from 'vitest'
10
+ import { mount, unmount } from 'svelte'
11
+ import SvGrid from './SvGrid.svelte'
12
+ import {
13
+ createCoreRowModel,
14
+ createFilteredRowModel,
15
+ createPaginatedRowModel,
16
+ createSortedRowModel,
17
+ flattenTreeData,
18
+ sortFns,
19
+ tableFeatures,
20
+ rowSortingFeature,
21
+ } from './index'
22
+
23
+ type Node = { id: number; managerId: number | null; name: string; team: string }
24
+
25
+ const data: Node[] = [
26
+ { id: 1, managerId: null, name: 'Ada', team: 'Exec' },
27
+ { id: 2, managerId: 1, name: 'Grace', team: 'Eng' },
28
+ { id: 3, managerId: 2, name: 'Alan', team: 'Eng' },
29
+ { id: 4, managerId: 1, name: 'Linus', team: 'Ops' },
30
+ ]
31
+
32
+ const columns = [
33
+ { field: 'name', header: 'Name', width: 200 },
34
+ { field: 'team', header: 'Team', width: 120 },
35
+ ]
36
+
37
+ const features = tableFeatures({ rowSortingFeature })
38
+ const tick = () => new Promise<void>((r) => queueMicrotask(r))
39
+
40
+ function mountGrid(extra: Record<string, unknown> = {}) {
41
+ const target = document.createElement('div')
42
+ document.body.appendChild(target)
43
+ const app = mount(SvGrid, {
44
+ target,
45
+ props: {
46
+ data,
47
+ columns,
48
+ features,
49
+ treeData: { parentField: 'managerId', column: 'name' },
50
+ _rowModels: {
51
+ coreRowModel: createCoreRowModel(),
52
+ filteredRowModel: createFilteredRowModel(),
53
+ sortedRowModel: createSortedRowModel(sortFns),
54
+ paginatedRowModel: createPaginatedRowModel(),
55
+ },
56
+ containerHeight: 600,
57
+ virtualization: false,
58
+ ...extra,
59
+ } as never,
60
+ })
61
+ return { target, destroy: () => { unmount(app); target.remove() } }
62
+ }
63
+
64
+ let cleanup: (() => void) | null = null
65
+ afterEach(() => { cleanup?.(); cleanup = null })
66
+
67
+ const bodyRows = (t: HTMLElement) =>
68
+ [...t.querySelectorAll('tbody tr.sv-grid-row')].filter(
69
+ (r) => !r.classList.contains('sv-grid-row-spacer'),
70
+ )
71
+ const toggles = (t: HTMLElement) =>
72
+ [...t.querySelectorAll<HTMLButtonElement>('.sv-grid-tree-toggle')]
73
+
74
+ describe('treeData', () => {
75
+ it('shows only roots until expanded', async () => {
76
+ const { target, destroy } = mountGrid()
77
+ cleanup = destroy
78
+ await tick()
79
+ expect(bodyRows(target)).toHaveLength(1)
80
+ expect(target.textContent).toContain('Ada')
81
+ expect(target.textContent).not.toContain('Grace')
82
+ })
83
+
84
+ it('expands to reveal children', async () => {
85
+ const { target, destroy } = mountGrid()
86
+ cleanup = destroy
87
+ await tick()
88
+ toggles(target)[0]!.click()
89
+ await tick()
90
+ expect(target.textContent).toContain('Grace')
91
+ expect(target.textContent).toContain('Linus')
92
+ // Alan is a grandchild - still hidden.
93
+ expect(target.textContent).not.toContain('Alan')
94
+ })
95
+
96
+ it('renders tree rows as normal cell rows, not full-width banners', async () => {
97
+ const { target, destroy } = mountGrid()
98
+ cleanup = destroy
99
+ await tick()
100
+ expect(target.querySelectorAll('tr.sv-grid-group-row')).toHaveLength(0)
101
+ // The root has its own cells - both columns render.
102
+ const cells = bodyRows(target)[0]!.querySelectorAll('.sv-grid-cell')
103
+ expect(cells.length).toBeGreaterThanOrEqual(2)
104
+ expect(bodyRows(target)[0]!.textContent).toContain('Exec')
105
+ })
106
+
107
+ it('uses the treegrid role with aria-level and aria-expanded', async () => {
108
+ const { target, destroy } = mountGrid()
109
+ cleanup = destroy
110
+ await tick()
111
+ expect(target.querySelector('[role="treegrid"]')).not.toBeNull()
112
+ const root = bodyRows(target)[0]!
113
+ expect(root.getAttribute('aria-level')).toBe('1')
114
+ expect(root.getAttribute('aria-expanded')).toBe('false')
115
+
116
+ toggles(target)[0]!.click()
117
+ await tick()
118
+ expect(bodyRows(target)[0]!.getAttribute('aria-expanded')).toBe('true')
119
+ // A child sits one level deeper.
120
+ expect(bodyRows(target)[1]!.getAttribute('aria-level')).toBe('2')
121
+ })
122
+
123
+ it('indents from ONE source only', async () => {
124
+ // `sv-grid-group-child-indent` exists for rows nested under a group banner
125
+ // and keys off `row.depth`, which tree rows also have. It used to stack on
126
+ // top of the tree indent, putting a 20px gap between a row's chevron and
127
+ // its text - the tree affordance owns indentation here.
128
+ const { target, destroy } = mountGrid()
129
+ cleanup = destroy
130
+ await tick()
131
+ toggles(target)[0]!.click()
132
+ await tick()
133
+
134
+ expect(target.querySelectorAll('.sv-grid-group-child-indent')).toHaveLength(0)
135
+ // Every name cell still carries exactly one tree indent.
136
+ const nameCells = [...target.querySelectorAll('[data-col-id="name"]')]
137
+ for (const cell of nameCells) {
138
+ expect(cell.querySelectorAll('.sv-grid-tree-indent')).toHaveLength(1)
139
+ }
140
+ })
141
+
142
+ it('indents by depth x indentPx, honouring the override', async () => {
143
+ const { target, destroy } = mountGrid({
144
+ treeData: { parentField: 'managerId', column: 'name', indentPx: 30 },
145
+ })
146
+ cleanup = destroy
147
+ await tick()
148
+ toggles(target)[0]!.click()
149
+ await tick()
150
+ const widths = [...target.querySelectorAll<HTMLElement>('[data-col-id="name"] .sv-grid-tree-indent')]
151
+ .map((e) => e.style.width)
152
+ // Root at 0, its children one level in.
153
+ expect(widths[0]).toBe('0px')
154
+ expect(widths[1]).toBe('30px')
155
+ })
156
+
157
+ it('gives a leaf no expander but keeps it indented', async () => {
158
+ const { target, destroy } = mountGrid()
159
+ cleanup = destroy
160
+ await tick()
161
+ toggles(target)[0]!.click()
162
+ await tick()
163
+ // Linus is a leaf: spacer, no toggle button.
164
+ const linus = bodyRows(target).find((r) => r.textContent?.includes('Linus'))!
165
+ expect(linus.querySelector('.sv-grid-tree-toggle')).toBeNull()
166
+ expect(linus.querySelector('.sv-grid-tree-spacer')).not.toBeNull()
167
+ expect(linus.querySelector('.sv-grid-tree-indent')).not.toBeNull()
168
+ })
169
+
170
+ it('accepts nested data once flattened', async () => {
171
+ const nested = [
172
+ { id: 1, name: 'Ada', team: 'Exec', reports: [{ id: 2, name: 'Grace', team: 'Eng' }] },
173
+ ]
174
+ const flat = flattenTreeData(nested as never, { childrenField: 'reports' })
175
+ const { target, destroy } = mountGrid({
176
+ data: flat,
177
+ treeData: { parentField: '__parentId', column: 'name' },
178
+ })
179
+ cleanup = destroy
180
+ await tick()
181
+ expect(bodyRows(target)).toHaveLength(1)
182
+ toggles(target)[0]!.click()
183
+ await tick()
184
+ expect(target.textContent).toContain('Grace')
185
+ })
186
+ })
@@ -0,0 +1,195 @@
1
+ /**
2
+ * The dropdown panel's geometry is derived from the option COUNT, and it used
3
+ * to be measured only when the panel opened.
4
+ *
5
+ * With an async `editorOptions` the list is empty at that moment, so the panel
6
+ * was anchored and sized for ZERO options - about 8px tall. The options then
7
+ * arrived and rendered outside that box, unstyled, with the grid rows visible
8
+ * behind them. Reported from demo 428 as a broken editor.
9
+ *
10
+ * jsdom has no layout, so these assert the contract that produces the right
11
+ * geometry: the panel re-measures the trigger whenever the visible option list
12
+ * changes while it is open.
13
+ */
14
+ import { afterEach, describe, expect, it } from 'vitest'
15
+ import { mount, unmount } from 'svelte'
16
+ import SvGridDropdown from './SvGridDropdown.svelte'
17
+
18
+ const tick = () => new Promise<void>((r) => setTimeout(r, 0))
19
+ const settle = async () => { for (let i = 0; i < 4; i += 1) await tick() }
20
+
21
+ let cleanup: (() => void) | null = null
22
+ afterEach(() => { cleanup?.(); cleanup = null })
23
+
24
+ type Opt = { value: string; label: string }
25
+ const opts = (...labels: string[]): Opt[] => labels.map((l) => ({ value: l, label: l }))
26
+
27
+ /** Count trigger measurements, which is how the panel is anchored + sized. */
28
+ function trackRectReads(rect?: Partial<DOMRect>) {
29
+ const reads: number[] = []
30
+ const orig = HTMLElement.prototype.getBoundingClientRect
31
+ HTMLElement.prototype.getBoundingClientRect = function () {
32
+ reads.push(1)
33
+ return {
34
+ top: 100, left: 0, width: 160, height: 30,
35
+ bottom: 130, right: 160, x: 0, y: 100, toJSON() {},
36
+ ...rect,
37
+ } as DOMRect
38
+ }
39
+ return { reads, restore: () => { HTMLElement.prototype.getBoundingClientRect = orig } }
40
+ }
41
+
42
+ const panelEl = () => document.querySelector('.sv-grid-dropdown-panel') as HTMLElement
43
+
44
+ const optionEls = () => document.querySelectorAll('.sv-grid-dropdown-option')
45
+ const emptyEl = () => document.querySelector('.sv-grid-dropdown-empty')
46
+
47
+ describe('dropdown panel with a late-arriving option list', () => {
48
+ it('re-measures the trigger when options land after opening', async () => {
49
+ const t = trackRectReads()
50
+ try {
51
+ const target = document.createElement('div')
52
+ document.body.appendChild(target)
53
+ // Opens with an empty list, exactly like an async source.
54
+ const props = $state({ options: [] as Opt[], value: null, onChange: () => {} })
55
+ const app = mount(SvGridDropdown, { target, props: props as never })
56
+ cleanup = () => { unmount(app); target.remove() }
57
+ await settle()
58
+
59
+ expect(optionEls()).toHaveLength(0)
60
+ const afterOpen = t.reads.length
61
+ expect(afterOpen).toBeGreaterThan(0)
62
+
63
+ // The request resolves.
64
+ props.options = opts('Paris', 'Lyon', 'Marseille', 'Toulouse', 'Bordeaux')
65
+ await settle()
66
+
67
+ expect(optionEls()).toHaveLength(5)
68
+ // Sized for an empty list and never re-measured is the bug; the panel has
69
+ // to consult the trigger again once it has real content.
70
+ expect(t.reads.length).toBeGreaterThan(afterOpen)
71
+ } finally {
72
+ t.restore()
73
+ }
74
+ })
75
+
76
+ it('re-measures when a search filter shrinks the list', async () => {
77
+ const t = trackRectReads()
78
+ try {
79
+ const target = document.createElement('div')
80
+ document.body.appendChild(target)
81
+ const props = $state({
82
+ options: opts('Paris', 'Lyon', 'Marseille'),
83
+ value: null, searchable: true, onChange: () => {},
84
+ })
85
+ const app = mount(SvGridDropdown, { target, props: props as never })
86
+ cleanup = () => { unmount(app); target.remove() }
87
+ await settle()
88
+ const before = t.reads.length
89
+
90
+ const search = document.querySelector('.sv-grid-dropdown-search') as HTMLInputElement
91
+ search.value = 'lyo'
92
+ search.dispatchEvent(new Event('input', { bubbles: true }))
93
+ await settle()
94
+
95
+ expect(optionEls()).toHaveLength(1)
96
+ expect(t.reads.length).toBeGreaterThan(before)
97
+ } finally {
98
+ t.restore()
99
+ }
100
+ })
101
+
102
+ it('shows Loading rather than "No options" while a list is in flight', async () => {
103
+ const target = document.createElement('div')
104
+ document.body.appendChild(target)
105
+ const app = mount(SvGridDropdown, {
106
+ target,
107
+ props: { options: [], value: null, loading: true, onChange: () => {} } as never,
108
+ })
109
+ cleanup = () => { unmount(app); target.remove() }
110
+ await settle()
111
+ expect(emptyEl()?.textContent).toContain('Loading')
112
+ // Announced, so a screen reader hears the list arrive.
113
+ expect(emptyEl()?.getAttribute('role')).toBe('status')
114
+ })
115
+
116
+ it('still says "No options" when the list is genuinely empty', async () => {
117
+ const target = document.createElement('div')
118
+ document.body.appendChild(target)
119
+ const app = mount(SvGridDropdown, {
120
+ target,
121
+ props: { options: [], value: null, loading: false, onChange: () => {} } as never,
122
+ })
123
+ cleanup = () => { unmount(app); target.remove() }
124
+ await settle()
125
+ expect(emptyEl()?.textContent).toContain('No options')
126
+ })
127
+
128
+ it('is not capped by the row-height estimate when the list fits', async () => {
129
+ // `anchoredRect` clamps its `maxHeight` to our estimate, which assumes a
130
+ // 32px row. Themes with a bigger font render taller rows, so capping the
131
+ // panel at that estimate cut off the last option. A short list must be
132
+ // limited only by the real room available.
133
+ const t = trackRectReads({ top: 100, bottom: 130 })
134
+ try {
135
+ const target = document.createElement('div')
136
+ document.body.appendChild(target)
137
+ const app = mount(SvGridDropdown, {
138
+ target,
139
+ props: { options: opts('FR', 'JP', 'US'), value: null, onChange: () => {} } as never,
140
+ })
141
+ cleanup = () => { unmount(app); target.remove() }
142
+ await settle()
143
+
144
+ const max = parseFloat(panelEl().style.maxHeight)
145
+ // The 3-row estimate is 3*32 + 8 = 104. Anything at or under that would
146
+ // clip; the real space below the trigger is far larger.
147
+ expect(max).toBeGreaterThan(104)
148
+ } finally {
149
+ t.restore()
150
+ }
151
+ })
152
+
153
+ it('anchors an upward panel by its bottom edge', async () => {
154
+ // Upward panels used to be positioned by `top`, derived from the same
155
+ // estimate. Now that the panel may exceed the estimate, a top-anchored one
156
+ // would grow DOWN over the trigger; bottom-anchoring makes it grow up.
157
+ const t = trackRectReads({ top: 4000, bottom: 4030 })
158
+ try {
159
+ const target = document.createElement('div')
160
+ document.body.appendChild(target)
161
+ const app = mount(SvGridDropdown, {
162
+ target,
163
+ props: {
164
+ options: opts(...Array.from({ length: 12 }, (_, i) => `Opt ${i}`)),
165
+ value: null, onChange: () => {},
166
+ } as never,
167
+ })
168
+ cleanup = () => { unmount(app); target.remove() }
169
+ await settle()
170
+
171
+ const el = panelEl()
172
+ expect(el.style.bottom).not.toBe('')
173
+ expect(el.style.top).toBe('')
174
+ } finally {
175
+ t.restore()
176
+ }
177
+ })
178
+
179
+ it('swaps Loading for the options once they resolve', async () => {
180
+ const target = document.createElement('div')
181
+ document.body.appendChild(target)
182
+ const props = $state({ options: [] as Opt[], value: null, loading: true, onChange: () => {} })
183
+ const app = mount(SvGridDropdown, { target, props: props as never })
184
+ cleanup = () => { unmount(app); target.remove() }
185
+ await settle()
186
+ expect(emptyEl()?.textContent).toContain('Loading')
187
+
188
+ props.options = opts('Paris', 'Lyon')
189
+ props.loading = false
190
+ await settle()
191
+
192
+ expect(emptyEl()).toBeNull()
193
+ expect(optionEls()).toHaveLength(2)
194
+ })
195
+ })
@@ -35,6 +35,28 @@ export type ThemePalette = {
35
35
  pinnedBorder?: string
36
36
  /** Drop shadow cast by a pinned column into the scroll area. */
37
37
  pinnedShadow?: string
38
+ /* Semantic status colors. The UI kit reads these for destructive buttons,
39
+ validation errors, alert variants, badges and the rating star. A preset
40
+ that omits them gets the mode-appropriate defaults below, which match the
41
+ inline fallbacks the components shipped with - so adding these changed
42
+ nothing for existing presets, it only made them themeable. */
43
+ /** Errors, destructive actions, invalid cells. */
44
+ danger?: string
45
+ /** Confirmations, positive deltas. */
46
+ success?: string
47
+ /** Cautions, pending states, the rating star. */
48
+ warning?: string
49
+ /** Neutral informational callouts. */
50
+ info?: string
51
+ /** Focus outline color. Defaults to the preset's accent. */
52
+ focusRing?: string
53
+ }
54
+
55
+ /** Status-color defaults per mode - dark mode lifts them so they stay legible
56
+ * on a dark ground. Values match the components' own inline fallbacks. */
57
+ const STATUS_DEFAULTS: Record<ThemeMode, { danger: string; success: string; warning: string; info: string }> = {
58
+ light: { danger: '#dc2626', success: '#16a34a', warning: '#d97706', info: '#0891b2' },
59
+ dark: { danger: '#f87171', success: '#4ade80', warning: '#fbbf24', info: '#22d3ee' },
38
60
  }
39
61
 
40
62
  export type ThemePreset = {
@@ -156,6 +178,7 @@ export function resolveThemeTokens(preset: ThemePreset | undefined, mode: ThemeM
156
178
  const resolved = preset ?? defaultThemePreset
157
179
  const p = resolved[mode]
158
180
  const accent = accentOverride || p.accent
181
+ const status = STATUS_DEFAULTS[mode]
159
182
  return {
160
183
  '--sg-bg': p.bg,
161
184
  '--sg-fg': p.fg,
@@ -194,6 +217,25 @@ export function resolveThemeTokens(preset: ThemePreset | undefined, mode: ThemeM
194
217
  '--sg-on-accent': onAccent(accent),
195
218
  '--sg-radius': `${resolved.radius}px`,
196
219
  '--sg-font': resolved.font,
220
+ // Semantic status colors. Same reasoning as the scrollbar tokens below:
221
+ // the UI kit consumes these (destructive buttons, alerts, validation,
222
+ // badges, the rating star), so a preset that doesn't define them leaves
223
+ // every status color at a hard-coded red/green/amber that ignores the
224
+ // theme and, on dark presets, sits at the wrong lightness.
225
+ '--sg-danger': p.danger ?? status.danger,
226
+ '--sg-success': p.success ?? status.success,
227
+ '--sg-warning': p.warning ?? status.warning,
228
+ '--sg-info': p.info ?? status.info,
229
+ // A COLOR, not a shadow - consumed as `outline: 2px solid var(...)`.
230
+ '--sg-focus-ring': p.focusRing ?? accent,
231
+ // Derived surfaces + shape the kit reads. Deriving from the palette keeps
232
+ // them following the preset and the light/dark mode.
233
+ '--sg-muted-bg': p.rowHover,
234
+ '--sg-skeleton-bg': p.rowHover,
235
+ '--sg-radius-lg': `${resolved.radius * 2}px`,
236
+ '--sg-rating-on': p.warning ?? status.warning,
237
+ '--sg-rating-empty': p.border,
238
+ '--sg-invalid-fg': p.danger ?? status.danger,
197
239
  // The grid's custom scrollbar is a shadow-DOM web component that reads its
198
240
  // OWN --sg-scrollbar-* tokens (with fixed light-mode fallbacks). Without
199
241
  // these it ignores the theme entirely - a light scrollbar left on a dark
@@ -20,7 +20,7 @@ describe('moveTreeNode', () => {
20
20
  it('moves inside a folder (appends to children)', () => {
21
21
  const out = moveTreeNode(tree, 'b', 'a', 'inside')
22
22
  expect(ids(out)).toEqual(['a', 'c'])
23
- expect(out[0].children!.map((n) => n.id)).toEqual(['a1', 'a2', 'b'])
23
+ expect(out[0]!.children!.map((n) => n.id)).toEqual(['a1', 'a2', 'b'])
24
24
  })
25
25
  it('refuses to drop a node into its own descendant, or onto itself', () => {
26
26
  expect(moveTreeNode(tree, 'a', 'a1', 'inside')).toEqual(tree)
@@ -37,13 +37,13 @@ describe('sortTreeNodes', () => {
37
37
  ]
38
38
  const asc = sortTreeNodes(unsorted, (x, y) => x.label.localeCompare(y.label))
39
39
  expect(ids(asc)).toEqual(['a', 'm', 'z'])
40
- expect(asc[1].children!.map((n) => n.label)).toEqual(['Apple', 'Pear'])
40
+ expect(asc[1]!.children!.map((n) => n.label)).toEqual(['Apple', 'Pear'])
41
41
  })
42
42
  })
43
43
 
44
44
  describe('treeContains', () => {
45
45
  it('detects descendants', () => {
46
- expect(treeContains(tree[0], 'a2')).toBe(true)
47
- expect(treeContains(tree[0], 'b')).toBe(false)
46
+ expect(treeContains(tree[0]!, 'a2')).toBe(true)
47
+ expect(treeContains(tree[0]!, 'b')).toBe(false)
48
48
  })
49
49
  })
@@ -0,0 +1,168 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createTreeRowModel, flattenTreeData } from './core'
3
+
4
+ type Node = { id: number; parent: number | null; name: string }
5
+
6
+ /** Minimal Row stand-ins - the model only reads `original`, `id` and spreads. */
7
+ function rowsFrom(data: Node[]) {
8
+ return data.map((original, index) => ({
9
+ id: String(original.id),
10
+ index,
11
+ original,
12
+ depth: 0,
13
+ getCanExpand: () => false,
14
+ getIsExpanded: () => false,
15
+ toggleExpanded: () => {},
16
+ getIsSelected: () => false,
17
+ toggleSelected: () => {},
18
+ getAllCells: () => [],
19
+ getCellValueByColumnId: (c: string) => (original as never as Record<string, unknown>)[c],
20
+ })) as never[]
21
+ }
22
+
23
+ let expandedState: Record<string, boolean> = {}
24
+ const table = {
25
+ getState: () => ({ expanded: expandedState }),
26
+ setExpanded: (fn: (p: Record<string, boolean>) => Record<string, boolean>) => {
27
+ expandedState = fn(expandedState)
28
+ },
29
+ } as never
30
+
31
+ const run = (data: Node[], opts = { parentField: 'parent' }) =>
32
+ createTreeRowModel<never>(opts)({ table, rows: rowsFrom(data) } as never)
33
+
34
+ const ids = (rows: readonly { id: string }[]) => rows.map((r) => r.id)
35
+
36
+ describe('createTreeRowModel', () => {
37
+ const data: Node[] = [
38
+ { id: 1, parent: null, name: 'root' },
39
+ { id: 2, parent: 1, name: 'child a' },
40
+ { id: 3, parent: 1, name: 'child b' },
41
+ { id: 4, parent: 2, name: 'grandchild' },
42
+ { id: 5, parent: null, name: 'other root' },
43
+ ]
44
+
45
+ it('nests by parent id and returns only roots', () => {
46
+ const out = run(data)
47
+ expect(ids(out)).toEqual(['1', '5'])
48
+ })
49
+
50
+ it('records depth down the chain', () => {
51
+ const [root] = run(data) as never as Array<{ depth: number; subRows: never[] }>
52
+ expect(root!.depth).toBe(0)
53
+ const child = (root!.subRows as never as Array<{ id: string; depth: number; subRows: never[] }>)[0]!
54
+ expect(child.depth).toBe(1)
55
+ expect((child.subRows as never as Array<{ depth: number }>)[0]!.depth).toBe(2)
56
+ })
57
+
58
+ it('marks rows with children expandable and leaves not', () => {
59
+ const [root, other] = run(data) as never as Array<{ getCanExpand: () => boolean }>
60
+ expect(root!.getCanExpand()).toBe(true)
61
+ expect(other!.getCanExpand()).toBe(false)
62
+ })
63
+
64
+ it('counts descendants, not just direct children', () => {
65
+ const [root] = run(data) as never as Array<{ leafCount: number }>
66
+ // children a + b, plus a's grandchild
67
+ expect(root!.leafCount).toBe(3)
68
+ })
69
+
70
+ it('tags rows so they are not mistaken for group banners', async () => {
71
+ const { isGroupRow } = await import('./cell-values')
72
+ const [root] = run(data) as never as Array<never>
73
+ expect((root as never as { __treeRow: boolean }).__treeRow).toBe(true)
74
+ // Expandable, but must NOT render as a full-width banner.
75
+ expect(isGroupRow(root!)).toBe(false)
76
+ })
77
+
78
+ it('drives expansion through the table state', () => {
79
+ expandedState = {}
80
+ const [root] = run(data) as never as Array<{
81
+ getIsExpanded: () => boolean
82
+ toggleExpanded: () => void
83
+ }>
84
+ expect(root!.getIsExpanded()).toBe(false)
85
+ root!.toggleExpanded()
86
+ expect(expandedState['1']).toBe(true)
87
+ })
88
+
89
+ it('promotes a row whose parent is missing rather than dropping it', () => {
90
+ // Parent 99 does not exist - e.g. the filter removed it. The row must still
91
+ // be reachable.
92
+ const orphaned: Node[] = [
93
+ { id: 1, parent: null, name: 'root' },
94
+ { id: 2, parent: 99, name: 'orphan' },
95
+ ]
96
+ expect(ids(run(orphaned))).toEqual(['1', '2'])
97
+ })
98
+
99
+ it('survives a parent cycle', () => {
100
+ const cyclic: Node[] = [
101
+ { id: 1, parent: 2, name: 'a' },
102
+ { id: 2, parent: 1, name: 'b' },
103
+ ]
104
+ // Neither is a root by parent link, so nothing is emitted rather than
105
+ // recursing forever. The guard is what matters.
106
+ expect(() => run(cyclic)).not.toThrow()
107
+ })
108
+
109
+ it('treats a self-parenting row as a root', () => {
110
+ expect(ids(run([{ id: 1, parent: 1, name: 'self' }]))).toEqual(['1'])
111
+ })
112
+
113
+ it('passes an empty set straight through', () => {
114
+ expect(run([])).toEqual([])
115
+ })
116
+
117
+ it('honours a custom idField', () => {
118
+ const keyed = [
119
+ { key: 10, parent: null },
120
+ { key: 11, parent: 10 },
121
+ ] as never as Node[]
122
+ const out = createTreeRowModel<never>({ parentField: 'parent', idField: 'key' })({
123
+ table,
124
+ rows: rowsFrom(keyed),
125
+ } as never) as never as Array<{ subRows: never[] }>
126
+ expect(out).toHaveLength(1)
127
+ expect(out[0]!.subRows).toHaveLength(1)
128
+ })
129
+ })
130
+
131
+ describe('flattenTreeData', () => {
132
+ const nested = [
133
+ { id: 1, name: 'a', kids: [{ id: 2, name: 'a1', kids: [{ id: 3, name: 'a1a' }] }] },
134
+ { id: 4, name: 'b' },
135
+ ]
136
+
137
+ it('emits children directly after their parent', () => {
138
+ const flat = flattenTreeData(nested as never, { childrenField: 'kids' })
139
+ expect(flat.map((r) => (r as never as { id: number }).id)).toEqual([1, 2, 3, 4])
140
+ })
141
+
142
+ it('stamps the parent link the tree model reads', () => {
143
+ const flat = flattenTreeData(nested as never, { childrenField: 'kids' }) as never as Array<
144
+ Record<string, unknown>
145
+ >
146
+ expect(flat[0]!.__parentId).toBeNull()
147
+ expect(flat[1]!.__parentId).toBe(1)
148
+ expect(flat[2]!.__parentId).toBe(2)
149
+ })
150
+
151
+ it('round-trips into createTreeRowModel', () => {
152
+ const flat = flattenTreeData(nested as never, { childrenField: 'kids' })
153
+ const out = createTreeRowModel<never>({ parentField: '__parentId' })({
154
+ table,
155
+ rows: rowsFrom(flat as never as Node[]),
156
+ } as never) as never as Array<{ id: string; subRows: never[] }>
157
+ expect(out.map((r) => r.id)).toEqual(['1', '4'])
158
+ expect(out[0]!.subRows).toHaveLength(1)
159
+ })
160
+
161
+ it('accepts a custom parent field name', () => {
162
+ const flat = flattenTreeData(nested as never, {
163
+ childrenField: 'kids',
164
+ parentField: 'pid',
165
+ }) as never as Array<Record<string, unknown>>
166
+ expect(flat[1]!.pid).toBe(1)
167
+ })
168
+ })