@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,204 @@
1
+ /**
2
+ * DOM: `autoRowHeight` sizes each row to its own content.
3
+ *
4
+ * jsdom does no layout - getBoundingClientRect is 0 and the global
5
+ * ResizeObserver stub never fires - so this file installs a controllable
6
+ * observer and fakes element heights. That is enough to prove the wiring that
7
+ * actually matters: rows are measured, the measurement reaches the virtualizer,
8
+ * and a measured row is sized by its content instead of the fixed rowHeight.
9
+ */
10
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
11
+ import { mount, unmount } from 'svelte'
12
+ import SvGrid from './SvGrid.svelte'
13
+ import {
14
+ createCoreRowModel,
15
+ createFilteredRowModel,
16
+ createSortedRowModel,
17
+ sortFns,
18
+ tableFeatures,
19
+ rowSortingFeature,
20
+ } from './index'
21
+
22
+ type Row = { id: number; name: string; notes: string }
23
+
24
+ const data: Row[] = [
25
+ { id: 1, name: 'Ada', notes: 'short' },
26
+ { id: 2, name: 'Linus', notes: 'a much longer note that would wrap onto several lines in a narrow column' },
27
+ { id: 3, name: 'Grace', notes: 'medium length note' },
28
+ ]
29
+
30
+ const columns = [
31
+ { field: 'name', header: 'Name', width: 120 },
32
+ { field: 'notes', header: 'Notes', width: 160 },
33
+ ]
34
+
35
+ const features = tableFeatures({ rowSortingFeature })
36
+ const tick = () => new Promise<void>((r) => queueMicrotask(r))
37
+ const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()))
38
+
39
+ /** A ResizeObserver whose callbacks this test can fire on demand. */
40
+ class ControllableRO {
41
+ static instances: ControllableRO[] = []
42
+ cb: () => void
43
+ targets: Element[] = []
44
+ constructor(cb: () => void) {
45
+ this.cb = cb
46
+ ControllableRO.instances.push(this)
47
+ }
48
+ observe(el: Element) { this.targets.push(el) }
49
+ unobserve() {}
50
+ disconnect() { this.targets = [] }
51
+ static fireAll() { for (const i of ControllableRO.instances) i.cb() }
52
+ static reset() { ControllableRO.instances = [] }
53
+ }
54
+
55
+ let originalRO: typeof globalThis.ResizeObserver
56
+ beforeEach(() => {
57
+ originalRO = globalThis.ResizeObserver
58
+ ControllableRO.reset()
59
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ControllableRO
60
+ })
61
+ afterEach(() => {
62
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = originalRO
63
+ })
64
+
65
+ function mountGrid(props: Record<string, unknown>) {
66
+ const target = document.createElement('div')
67
+ document.body.appendChild(target)
68
+ const app = mount(SvGrid, {
69
+ target,
70
+ props: {
71
+ data,
72
+ columns,
73
+ features,
74
+ rowHeight: 30,
75
+ _rowModels: {
76
+ coreRowModel: createCoreRowModel(),
77
+ filteredRowModel: createFilteredRowModel(),
78
+ sortedRowModel: createSortedRowModel(sortFns),
79
+ },
80
+ containerHeight: 400,
81
+ virtualization: false,
82
+ ...props,
83
+ } as never,
84
+ })
85
+ return { target, destroy: () => { unmount(app); target.remove() } }
86
+ }
87
+
88
+ let cleanup: (() => void) | null = null
89
+ afterEach(() => { cleanup?.(); cleanup = null })
90
+
91
+ const dataRows = (t: HTMLElement) =>
92
+ [...t.querySelectorAll<HTMLElement>('tbody tr.sv-grid-row')].filter((r) => !r.classList.contains('sv-grid-group-row'))
93
+
94
+ describe('autoRowHeight', () => {
95
+ it('is off by default: rows get a fixed height and no wrap class', async () => {
96
+ const g = mountGrid({})
97
+ cleanup = g.destroy
98
+ await tick()
99
+ const row = dataRows(g.target)[0]!
100
+ expect(row.classList.contains('sv-grid-row-auto-height')).toBe(false)
101
+ expect(row.getAttribute('style')).toContain('height: 30px')
102
+ expect(row.getAttribute('style')).not.toContain('min-height')
103
+ })
104
+
105
+ it('switches rows to min-height + the wrap class when on', async () => {
106
+ const g = mountGrid({ autoRowHeight: true })
107
+ cleanup = g.destroy
108
+ await tick()
109
+ for (const row of dataRows(g.target)) {
110
+ expect(row.classList.contains('sv-grid-row-auto-height')).toBe(true)
111
+ // min-height, so content can grow the row instead of being clipped.
112
+ expect(row.getAttribute('style')).toContain('min-height: 30px')
113
+ }
114
+ })
115
+
116
+ it('feeds a measured height back into the row size', async () => {
117
+ const g = mountGrid({ autoRowHeight: true })
118
+ cleanup = g.destroy
119
+ await tick()
120
+
121
+ const rows = dataRows(g.target)
122
+ expect(rows.length).toBe(3)
123
+ // Fake layout: row 1 (the long note) wraps to 3 lines.
124
+ const heights = [30, 90, 30]
125
+ rows.forEach((row, i) => {
126
+ row.getBoundingClientRect = () => ({ height: heights[i], width: 280, top: 0, left: 0, right: 280, bottom: heights[i]!, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect
127
+ })
128
+
129
+ ControllableRO.fireAll()
130
+ await raf()
131
+ await tick()
132
+
133
+ const after = dataRows(g.target)
134
+ expect(after[0]?.getAttribute('style')).toContain('min-height: 30px')
135
+ // The measured tall row is now sized by its content, not by rowHeight.
136
+ expect(after[1]?.getAttribute('style')).toContain('min-height: 90px')
137
+ expect(after[2]?.getAttribute('style')).toContain('min-height: 30px')
138
+ })
139
+
140
+ it('ignores autoRowHeight when rowHeight is a function (caller owns heights)', async () => {
141
+ const g = mountGrid({ autoRowHeight: true, rowHeight: (i: number) => 40 + i * 10 })
142
+ cleanup = g.destroy
143
+ await tick()
144
+ const rows = dataRows(g.target)
145
+ expect(rows[0]?.classList.contains('sv-grid-row-auto-height')).toBe(false)
146
+ expect(rows[0]?.getAttribute('style')).toContain('height: 40px')
147
+ expect(rows[1]?.getAttribute('style')).toContain('height: 50px')
148
+ })
149
+
150
+ it('uses a numeric rowHeight as the pre-measure estimate', async () => {
151
+ const g = mountGrid({ autoRowHeight: true, rowHeight: 48 })
152
+ cleanup = g.destroy
153
+ await tick()
154
+ // Nothing measured yet, so every row sits at the estimate.
155
+ for (const row of dataRows(g.target)) {
156
+ expect(row.getAttribute('style')).toContain('min-height: 48px')
157
+ }
158
+ })
159
+
160
+ it('works on the virtualized body too (the variable-size path)', async () => {
161
+ const g = mountGrid({ autoRowHeight: true, virtualization: true })
162
+ cleanup = g.destroy
163
+ await tick()
164
+ const rows = dataRows(g.target)
165
+ expect(rows.length).toBeGreaterThan(0)
166
+ expect(rows[0]?.classList.contains('sv-grid-row-auto-height')).toBe(true)
167
+
168
+ rows.forEach((row, i) => {
169
+ const h = i === 1 ? 96 : 30
170
+ row.getBoundingClientRect = () => ({ height: h, width: 280, top: 0, left: 0, right: 280, bottom: h, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect
171
+ })
172
+ ControllableRO.fireAll()
173
+ await raf()
174
+ await tick()
175
+
176
+ const after = dataRows(g.target)
177
+ // The virtualizer now sizes row 1 from its measurement, so the row element
178
+ // reflects the measured height rather than the 30px estimate.
179
+ expect(after[1]?.getAttribute('style')).toContain('96px')
180
+ })
181
+
182
+ it('does not re-report a sub-pixel change (no measure/layout loop)', async () => {
183
+ const g = mountGrid({ autoRowHeight: true })
184
+ cleanup = g.destroy
185
+ await tick()
186
+ const rows = dataRows(g.target)
187
+ rows.forEach((row) => {
188
+ row.getBoundingClientRect = () => ({ height: 70, width: 280, top: 0, left: 0, right: 280, bottom: 70, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect
189
+ })
190
+ ControllableRO.fireAll()
191
+ await raf()
192
+ await tick()
193
+ expect(dataRows(g.target)[0]?.getAttribute('style')).toContain('min-height: 70px')
194
+
195
+ // A 0.4px drift must be ignored - otherwise fractional layout ping-pongs.
196
+ dataRows(g.target).forEach((row) => {
197
+ row.getBoundingClientRect = () => ({ height: 70.4, width: 280, top: 0, left: 0, right: 280, bottom: 70.4, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect
198
+ })
199
+ ControllableRO.fireAll()
200
+ await raf()
201
+ await tick()
202
+ expect(dataRows(g.target)[0]?.getAttribute('style')).toContain('min-height: 70px')
203
+ })
204
+ })
@@ -25,7 +25,6 @@ import {
25
25
  createGroupedRowModel,
26
26
  createPaginatedRowModel,
27
27
  createSortedRowModel,
28
- renderSnippet,
29
28
  rowExpandingFeature,
30
29
  rowPaginationFeature,
31
30
  rowSelectionFeature,
@@ -19,7 +19,7 @@ import {
19
19
  formatChartValue,
20
20
  buildChart,
21
21
  } from './index'
22
- import type { ColumnDef, SvGridApi } from './index'
22
+ import type { ChartAggregateRequest, ColumnDef, SvGridApi } from './index'
23
23
 
24
24
  type Person = { id: number; name: string; team: string; age: number; salary: number }
25
25
 
@@ -328,7 +328,7 @@ describe('SvGrid built-in charting', () => {
328
328
  await tick()
329
329
  const csv = chartSpecToCsv(api.getChartSpec()!)
330
330
  const [header, ...rows] = csv.split('\n')
331
- expect(header.startsWith('Category,')).toBe(true)
331
+ expect(header!.startsWith('Category,')).toBe(true)
332
332
  expect(rows.length).toBeGreaterThan(0)
333
333
  // Export button + menu.
334
334
  const btn = target.querySelector<HTMLButtonElement>('[aria-label="Export chart"]')
@@ -435,7 +435,7 @@ describe('SvGrid built-in charting', () => {
435
435
 
436
436
  it('renders a custom `buildSpec` chart that stays live with the grid', async () => {
437
437
  const { api, destroy } = await mountGrid({
438
- charting: { defaultOpen: true, buildSpec: (rows) => ({ type: 'bar', categories: ['All'], series: [{ label: 'Count', values: [rows.length] }] }) },
438
+ charting: { defaultOpen: true, buildSpec: (rows: unknown[]) => ({ type: 'bar', categories: ['All'], series: [{ label: 'Count', values: [rows.length] }] }) },
439
439
  })
440
440
  try {
441
441
  await tick()
@@ -487,7 +487,7 @@ describe('SvGrid built-in charting', () => {
487
487
  let lastFilter: any = null
488
488
  const { api, destroy } = await mountGrid({
489
489
  charting: {
490
- getAggregate: async (req) => {
490
+ getAggregate: async (req: ChartAggregateRequest) => {
491
491
  calls += 1
492
492
  lastFilter = req.filterModel
493
493
  return [
@@ -106,14 +106,6 @@ function headerOrder(target: HTMLElement): string[] {
106
106
  return Array.from(ths).map((th) => th.dataset.svgridHeaderCol ?? '')
107
107
  }
108
108
 
109
- function header(target: HTMLElement, id: string): HTMLElement {
110
- // jsdom doesn't ship CSS.escape; our ids are simple alphanumerics so
111
- // a plain attribute selector is fine.
112
- const el = target.querySelector<HTMLElement>(`th[data-svgrid-header-col="${id}"]`)
113
- if (!el) throw new Error(`header not found: ${id}`)
114
- return el
115
- }
116
-
117
109
  describe('SvGrid column reorder - default off', () => {
118
110
  it('does NOT set draggable on headers when the prop is unset', async () => {
119
111
  const { target, destroy } = await mountGrid()
@@ -94,8 +94,14 @@ describe('editable comments', () => {
94
94
  const cell = dataCell(target, 0)
95
95
  cell.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true }))
96
96
  // First menu-open triggers GridMenus' lazy import; poll until it mounts.
97
- await vi.waitFor(() => expect(target.querySelector('.sv-grid-context-menu')).not.toBeNull())
98
- await tick()
97
+ // Wait for the menu's ITEMS, not just the overlay element: GridMenus is a
98
+ // lazy chunk and the overlay mounts before its items render, so a fixed
99
+ // tick afterwards is a race rather than a guarantee.
100
+ await vi.waitFor(() => {
101
+ const menu = target.querySelector('.sv-grid-context-menu')
102
+ expect(menu).not.toBeNull()
103
+ expect(menu!.querySelectorAll('.sv-grid-menu-item').length).toBeGreaterThan(0)
104
+ })
99
105
  const editItem = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].find(
100
106
  (b) => b.textContent?.trim() === 'Edit comment',
101
107
  ) as HTMLElement
@@ -74,17 +74,31 @@ function rightClickFirstCell(target: HTMLElement) {
74
74
  return cell
75
75
  }
76
76
 
77
+ /**
78
+ * Right-click and wait until the menu's ITEMS are in the DOM.
79
+ *
80
+ * GridMenus is a lazy chunk, and the overlay element appears before its items
81
+ * render. Waiting for the element alone leaves callers reading an empty item
82
+ * list; waiting a fixed tick makes them depend on an earlier test having
83
+ * already warmed the chunk. Only for cases that expect the menu to open - the
84
+ * "stays closed" case must not use this.
85
+ */
86
+ async function openContextMenu(target: HTMLElement) {
87
+ rightClickFirstCell(target)
88
+ await vi.waitFor(() => {
89
+ const menu = target.querySelector('.sv-grid-context-menu')
90
+ expect(menu).not.toBeNull()
91
+ expect(menu!.querySelectorAll('.sv-grid-menu-item').length).toBeGreaterThan(0)
92
+ })
93
+ return target.querySelector('.sv-grid-context-menu') as HTMLElement
94
+ }
95
+
77
96
  describe('SvGrid context menu', () => {
78
97
  it('opens the default menu on right-click with the built-in items', async () => {
79
98
  const { target, destroy } = await mountGrid(true)
80
99
  await tick()
81
- rightClickFirstCell(target)
82
- // First menu-open triggers GridMenus' lazy import (transforming the module on
83
- // first load can exceed a single tick); poll until the overlay mounts.
84
- await vi.waitFor(() => expect(target.querySelector('.sv-grid-context-menu')).not.toBeNull())
85
- const menu = target.querySelector('.sv-grid-context-menu')
86
- expect(menu).not.toBeNull()
87
- const labels = [...menu!.querySelectorAll('.sv-grid-menu-item')].map((b) => b.textContent?.trim())
100
+ const menu = await openContextMenu(target)
101
+ const labels = [...menu.querySelectorAll('.sv-grid-menu-item')].map((b) => b.textContent?.trim())
88
102
  expect(labels).toContain('Copy')
89
103
  expect(labels).toContain('Paste')
90
104
  expect(labels).toContain('Remove row')
@@ -113,8 +127,10 @@ describe('SvGrid context menu', () => {
113
127
  ]
114
128
  const { target, destroy } = await mountGrid(items)
115
129
  await tick()
116
- rightClickFirstCell(target)
117
- await tick()
130
+ // Was a bare `await tick()`, which only worked because the case above had
131
+ // already loaded the lazy chunk - running this one alone would have read an
132
+ // empty menu.
133
+ await openContextMenu(target)
118
134
  const labels = [...target.querySelectorAll('.sv-grid-context-menu .sv-grid-menu-item')].map((b) =>
119
135
  b.textContent?.trim(),
120
136
  )
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Filtering depth, verified rather than assumed (#11, #12).
3
+ *
4
+ * Both of these were still listed as roadmap gaps. They are not: the filter row
5
+ * renders a per-column operator picker, and a column filter carries an optional
6
+ * second condition joined by AND/OR. These tests pin the behaviour so the claim
7
+ * is backed by something executable.
8
+ */
9
+ import { afterEach, describe, expect, it } from 'vitest'
10
+ import { mount, unmount } from 'svelte'
11
+ import SvGrid from './SvGrid.svelte'
12
+ import {
13
+ applyExcelFilter,
14
+ createCoreRowModel,
15
+ createFilteredRowModel,
16
+ createPaginatedRowModel,
17
+ createSortedRowModel,
18
+ sortFns,
19
+ tableFeatures,
20
+ rowSortingFeature,
21
+ columnFilteringFeature,
22
+ } from './index'
23
+
24
+ type Row = { id: number; name: string; amount: number }
25
+
26
+ const data: Row[] = [
27
+ { id: 1, name: 'alpha', amount: 5 },
28
+ { id: 2, name: 'beta', amount: 50 },
29
+ { id: 3, name: 'gamma', amount: 500 },
30
+ ]
31
+
32
+ const columns = [
33
+ { field: 'name', header: 'Name', width: 160 },
34
+ { field: 'amount', header: 'Amount', width: 120 },
35
+ ]
36
+
37
+ const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })
38
+ const tick = () => new Promise<void>((r) => queueMicrotask(r))
39
+
40
+ function mountGrid() {
41
+ const target = document.createElement('div')
42
+ document.body.appendChild(target)
43
+ let api: never = null as never
44
+ const app = mount(SvGrid, {
45
+ target,
46
+ props: {
47
+ data,
48
+ columns,
49
+ features,
50
+ filterable: true,
51
+ showFilterRow: true,
52
+ onApiReady: (a: never) => { api = a },
53
+ _rowModels: {
54
+ coreRowModel: createCoreRowModel(),
55
+ filteredRowModel: createFilteredRowModel(),
56
+ sortedRowModel: createSortedRowModel(sortFns),
57
+ paginatedRowModel: createPaginatedRowModel(),
58
+ },
59
+ containerHeight: 400,
60
+ virtualization: false,
61
+ } as never,
62
+ })
63
+ return { target, getApi: () => api, destroy: () => { unmount(app); target.remove() } }
64
+ }
65
+
66
+ let cleanup: (() => void) | null = null
67
+ afterEach(() => { cleanup?.(); cleanup = null })
68
+
69
+ const bodyRows = (t: HTMLElement) =>
70
+ [...t.querySelectorAll('tbody tr.sv-grid-row')].filter(
71
+ (r) => !r.classList.contains('sv-grid-row-spacer'),
72
+ )
73
+
74
+ describe('filter row operator picker (#11)', () => {
75
+ it('renders an operator button per column in the filter row', async () => {
76
+ const { target, destroy } = mountGrid()
77
+ cleanup = destroy
78
+ await tick()
79
+ const btns = target.querySelectorAll('.sv-grid-filter-operator-btn')
80
+ expect(btns.length).toBe(columns.length)
81
+ })
82
+
83
+ it('exposes the active operator on the button for a screen reader', async () => {
84
+ const { target, destroy } = mountGrid()
85
+ cleanup = destroy
86
+ await tick()
87
+ const btn = target.querySelector('.sv-grid-filter-operator-btn')!
88
+ expect(btn.getAttribute('aria-label')).toMatch(/^Filter condition: /)
89
+ })
90
+
91
+ it('opens a menu of the operators valid for that column', async () => {
92
+ const { target, destroy } = mountGrid()
93
+ cleanup = destroy
94
+ await tick()
95
+ target.querySelector<HTMLButtonElement>('.sv-grid-filter-operator-btn')!.click()
96
+ await tick()
97
+ expect(target.querySelector('.sv-grid-filter-operator-btn.is-open')).not.toBeNull()
98
+ })
99
+ })
100
+
101
+ describe('two-condition column filter (#12)', () => {
102
+ // The model is what decides rows; these assert the AND/OR semantics directly.
103
+ const between = (v: unknown, lo: number, hi: number) =>
104
+ applyExcelFilter(v, { id: 'amount', operator: 'greaterThan', value: lo }) &&
105
+ applyExcelFilter(v, { id: 'amount', operator: 'lessThan', value: hi })
106
+
107
+ it('AND narrows to the intersection', () => {
108
+ expect(between(50, 10, 100)).toBe(true)
109
+ expect(between(5, 10, 100)).toBe(false)
110
+ expect(between(500, 10, 100)).toBe(false)
111
+ })
112
+
113
+ it('OR widens to the union', () => {
114
+ const or = (v: unknown) =>
115
+ applyExcelFilter(v, { id: 'name', operator: 'startsWith', value: 'al' }) ||
116
+ applyExcelFilter(v, { id: 'name', operator: 'endsWith', value: 'ma' })
117
+ expect(or('alpha')).toBe(true)
118
+ expect(or('gamma')).toBe(true)
119
+ expect(or('beta')).toBe(false)
120
+ })
121
+
122
+ it('applies a two-condition AND filter through the api', async () => {
123
+ const g = mountGrid()
124
+ cleanup = g.destroy
125
+ await tick()
126
+ const api = g.getApi() as never as {
127
+ setFilter: (id: string, f: Record<string, unknown>) => void
128
+ }
129
+ // amount > 10 AND amount < 100 -> only beta (50)
130
+ api.setFilter('amount', {
131
+ operator: 'greaterThan',
132
+ value: '10',
133
+ operator2: 'lessThan',
134
+ value2: '100',
135
+ join: 'AND',
136
+ })
137
+ await tick()
138
+ expect(bodyRows(g.target)).toHaveLength(1)
139
+ expect(g.target.textContent).toContain('beta')
140
+ })
141
+
142
+ it('applies a two-condition OR filter through the api', async () => {
143
+ const g = mountGrid()
144
+ cleanup = g.destroy
145
+ await tick()
146
+ const api = g.getApi() as never as {
147
+ setFilter: (id: string, f: Record<string, unknown>) => void
148
+ }
149
+ // name startsWith "al" OR name endsWith "ma" -> alpha + gamma
150
+ api.setFilter('name', {
151
+ operator: 'startsWith',
152
+ value: 'al',
153
+ operator2: 'endsWith',
154
+ value2: 'ma',
155
+ join: 'OR',
156
+ })
157
+ await tick()
158
+ expect(bodyRows(g.target)).toHaveLength(2)
159
+ expect(g.target.textContent).toContain('alpha')
160
+ expect(g.target.textContent).toContain('gamma')
161
+ expect(g.target.textContent).not.toContain('beta')
162
+ })
163
+ })
@@ -82,8 +82,22 @@ function mountGrid(data: Row[]) {
82
82
  async function openFilterMenu(target: HTMLElement) {
83
83
  const btn = target.querySelector('.sv-grid-col-filter-btn') as HTMLButtonElement
84
84
  btn.click()
85
- // GridMenus is a lazy chunk; poll until the popover mounts.
86
- await vi.waitFor(() => expect(target.querySelector('.sv-grid-filter-menu')).not.toBeNull())
85
+ // GridMenus is a lazy chunk, so poll until the popover mounts AND its value
86
+ // checklist has rendered.
87
+ //
88
+ // Waiting only for the popover element returns while the listbox is still
89
+ // empty: the chunk arrives, the menu element appears, and the values are
90
+ // snapshotted and windowed in a later flush. Callers then read option counts
91
+ // from a list that has not populated yet. It survives locally because those
92
+ // flushes land in the same task, but under parallel load the gap widens - it
93
+ // failed once that way in a full-suite run. Every caller here opens a menu on
94
+ // a column that has values, so a populated list is the settled state to wait
95
+ // for, and each test's own assertions are unchanged.
96
+ await vi.waitFor(() => {
97
+ const menu = target.querySelector('.sv-grid-filter-menu')
98
+ expect(menu).not.toBeNull()
99
+ expect(menu!.querySelectorAll('[role="option"]').length).toBeGreaterThan(0)
100
+ })
87
101
  return target.querySelector('.sv-grid-filter-menu') as HTMLElement
88
102
  }
89
103
 
@@ -64,8 +64,16 @@ async function openFilterMenu(target: HTMLElement) {
64
64
  const btn = target.querySelector('.sv-grid-col-filter-btn') as HTMLButtonElement
65
65
  expect(btn).not.toBeNull()
66
66
  btn.click()
67
- // GridMenus is a lazy chunk; poll until the popover mounts.
68
- await vi.waitFor(() => expect(target.querySelector('.sv-grid-filter-menu')).not.toBeNull())
67
+ // GridMenus is a lazy chunk, so poll until the popover mounts AND its value
68
+ // checklist is in the DOM. Waiting only for the popover element returns while
69
+ // the menu body is still filling in, so a caller that reaches for
70
+ // `.sv-listbox` can get null - which is how this file flaked under
71
+ // full-suite parallel load.
72
+ await vi.waitFor(() => {
73
+ const menu = target.querySelector('.sv-grid-filter-menu')
74
+ expect(menu).not.toBeNull()
75
+ expect(menu!.querySelector('.sv-listbox')).not.toBeNull()
76
+ })
69
77
  return target.querySelector('.sv-grid-filter-menu') as HTMLElement
70
78
  }
71
79