@svgrid/grid 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE +33 -0
  2. package/README.md +39 -0
  3. package/dist/FlexRender.svelte +96 -0
  4. package/dist/FlexRender.svelte.d.ts +49 -0
  5. package/dist/SvGrid.svelte +8742 -0
  6. package/dist/SvGrid.svelte.d.ts +381 -0
  7. package/dist/SvGridChart.svelte +653 -0
  8. package/dist/SvGridChart.svelte.d.ts +25 -0
  9. package/dist/SvGridChart.test.d.ts +1 -0
  10. package/dist/SvGridChart.test.js +62 -0
  11. package/dist/SvGridDropdown.svelte +675 -0
  12. package/dist/SvGridDropdown.svelte.d.ts +40 -0
  13. package/dist/a11y.contract.test.d.ts +1 -0
  14. package/dist/a11y.contract.test.js +39 -0
  15. package/dist/a11y.d.ts +38 -0
  16. package/dist/a11y.js +37 -0
  17. package/dist/a11y.test.d.ts +1 -0
  18. package/dist/a11y.test.js +44 -0
  19. package/dist/cell-formatting.d.ts +17 -0
  20. package/dist/cell-formatting.js +141 -0
  21. package/dist/cell-formatting.test.d.ts +1 -0
  22. package/dist/cell-formatting.test.js +234 -0
  23. package/dist/chart-export.d.ts +14 -0
  24. package/dist/chart-export.js +112 -0
  25. package/dist/chart.d.ts +226 -0
  26. package/dist/chart.js +732 -0
  27. package/dist/chart.test.d.ts +1 -0
  28. package/dist/chart.test.js +289 -0
  29. package/dist/collaboration.d.ts +74 -0
  30. package/dist/collaboration.js +98 -0
  31. package/dist/collaboration.test.d.ts +1 -0
  32. package/dist/collaboration.test.js +65 -0
  33. package/dist/conditional-formatting.d.ts +91 -0
  34. package/dist/conditional-formatting.js +170 -0
  35. package/dist/conditional-formatting.test.d.ts +1 -0
  36. package/dist/conditional-formatting.test.js +87 -0
  37. package/dist/core.coverage.test.d.ts +1 -0
  38. package/dist/core.coverage.test.js +186 -0
  39. package/dist/core.d.ts +370 -0
  40. package/dist/core.js +568 -0
  41. package/dist/core.performance.test.d.ts +1 -0
  42. package/dist/core.performance.test.js +29 -0
  43. package/dist/createGrid.svelte.d.ts +6 -0
  44. package/dist/createGrid.svelte.js +17 -0
  45. package/dist/createGrid.test.d.ts +1 -0
  46. package/dist/createGrid.test.js +9 -0
  47. package/dist/createGridState.svelte.d.ts +3 -0
  48. package/dist/createGridState.svelte.js +13 -0
  49. package/dist/editors/cell-editors.d.ts +23 -0
  50. package/dist/editors/cell-editors.js +97 -0
  51. package/dist/editors/cell-editors.test.d.ts +1 -0
  52. package/dist/editors/cell-editors.test.js +75 -0
  53. package/dist/fill-patterns.d.ts +30 -0
  54. package/dist/fill-patterns.js +207 -0
  55. package/dist/fill-patterns.test.d.ts +1 -0
  56. package/dist/fill-patterns.test.js +81 -0
  57. package/dist/filtering/excel-filters.d.ts +26 -0
  58. package/dist/filtering/excel-filters.js +64 -0
  59. package/dist/filtering/excel-filters.test.d.ts +1 -0
  60. package/dist/filtering/excel-filters.test.js +116 -0
  61. package/dist/filtering/locale-filter.test.d.ts +13 -0
  62. package/dist/filtering/locale-filter.test.js +189 -0
  63. package/dist/flex-render.d.ts +1 -0
  64. package/dist/flex-render.js +2 -0
  65. package/dist/flex-render.test.d.ts +1 -0
  66. package/dist/flex-render.test.js +142 -0
  67. package/dist/group-aggregate.test.d.ts +1 -0
  68. package/dist/group-aggregate.test.js +32 -0
  69. package/dist/index.d.ts +29 -0
  70. package/dist/index.js +27 -0
  71. package/dist/keyboard.d.ts +8 -0
  72. package/dist/keyboard.js +91 -0
  73. package/dist/keyboard.test.d.ts +1 -0
  74. package/dist/keyboard.test.js +27 -0
  75. package/dist/merge-objects.d.ts +9 -0
  76. package/dist/merge-objects.js +28 -0
  77. package/dist/named-views.d.ts +42 -0
  78. package/dist/named-views.js +80 -0
  79. package/dist/named-views.test.d.ts +1 -0
  80. package/dist/named-views.test.js +57 -0
  81. package/dist/new-features.test.d.ts +1 -0
  82. package/dist/new-features.test.js +183 -0
  83. package/dist/render-component.d.ts +13 -0
  84. package/dist/render-component.js +14 -0
  85. package/dist/render-component.test.d.ts +1 -0
  86. package/dist/render-component.test.js +41 -0
  87. package/dist/server-data-source.d.ts +76 -0
  88. package/dist/server-data-source.js +83 -0
  89. package/dist/server-data-source.test.d.ts +1 -0
  90. package/dist/server-data-source.test.js +81 -0
  91. package/dist/sparkline.d.ts +56 -0
  92. package/dist/sparkline.js +97 -0
  93. package/dist/sparkline.test.d.ts +1 -0
  94. package/dist/sparkline.test.js +52 -0
  95. package/dist/static-functions.d.ts +1 -0
  96. package/dist/static-functions.js +1 -0
  97. package/dist/subscribe.d.ts +9 -0
  98. package/dist/subscribe.js +31 -0
  99. package/dist/subscribe.test.d.ts +1 -0
  100. package/dist/subscribe.test.js +93 -0
  101. package/dist/sv-grid-scrollbar.d.ts +1 -0
  102. package/dist/sv-grid-scrollbar.js +310 -0
  103. package/dist/svgrid-wrapper.types.d.ts +341 -0
  104. package/dist/svgrid-wrapper.types.js +1 -0
  105. package/dist/svgrid.api-extensions.test.d.ts +14 -0
  106. package/dist/svgrid.api-extensions.test.js +334 -0
  107. package/dist/svgrid.api.test.d.ts +15 -0
  108. package/dist/svgrid.api.test.js +169 -0
  109. package/dist/svgrid.behavior.test.d.ts +15 -0
  110. package/dist/svgrid.behavior.test.js +605 -0
  111. package/dist/svgrid.column-reorder.test.d.ts +15 -0
  112. package/dist/svgrid.column-reorder.test.js +224 -0
  113. package/dist/svgrid.features.test.d.ts +1 -0
  114. package/dist/svgrid.features.test.js +129 -0
  115. package/dist/svgrid.interaction.test.d.ts +9 -0
  116. package/dist/svgrid.interaction.test.js +318 -0
  117. package/dist/svgrid.locale-filtering.test.d.ts +15 -0
  118. package/dist/svgrid.locale-filtering.test.js +261 -0
  119. package/dist/svgrid.new-features.wrapper.test.d.ts +1 -0
  120. package/dist/svgrid.new-features.wrapper.test.js +136 -0
  121. package/dist/svgrid.row-pinning.test.d.ts +14 -0
  122. package/dist/svgrid.row-pinning.test.js +250 -0
  123. package/dist/svgrid.set-filter.test.d.ts +17 -0
  124. package/dist/svgrid.set-filter.test.js +268 -0
  125. package/dist/svgrid.wrapper.test.d.ts +1 -0
  126. package/dist/svgrid.wrapper.test.js +33 -0
  127. package/dist/test-setup.d.ts +1 -0
  128. package/dist/test-setup.js +29 -0
  129. package/dist/transaction.test.d.ts +1 -0
  130. package/dist/transaction.test.js +89 -0
  131. package/dist/virtualization/column-virtualizer.d.ts +20 -0
  132. package/dist/virtualization/column-virtualizer.js +19 -0
  133. package/dist/virtualization/column-virtualizer.test.d.ts +1 -0
  134. package/dist/virtualization/column-virtualizer.test.js +25 -0
  135. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +11 -0
  136. package/dist/virtualization/svelte-virtualizer.svelte.js +20 -0
  137. package/dist/virtualization/types.d.ts +28 -0
  138. package/dist/virtualization/types.js +1 -0
  139. package/dist/virtualization/virtualizer.d.ts +13 -0
  140. package/dist/virtualization/virtualizer.js +232 -0
  141. package/dist/virtualization/virtualizer.test.d.ts +1 -0
  142. package/dist/virtualization/virtualizer.test.js +41 -0
  143. package/package.json +69 -0
  144. package/src/FlexRender.svelte +96 -0
  145. package/src/SvGrid.svelte +8742 -0
  146. package/src/SvGridChart.svelte +653 -0
  147. package/src/SvGridChart.test.ts +69 -0
  148. package/src/SvGridDropdown.svelte +675 -0
  149. package/src/a11y.contract.test.ts +49 -0
  150. package/src/a11y.test.ts +59 -0
  151. package/src/a11y.ts +59 -0
  152. package/src/cell-formatting.test.ts +273 -0
  153. package/src/cell-formatting.ts +169 -0
  154. package/src/chart-export.ts +144 -0
  155. package/src/chart.test.ts +311 -0
  156. package/src/chart.ts +934 -0
  157. package/src/collaboration.test.ts +74 -0
  158. package/src/collaboration.ts +161 -0
  159. package/src/conditional-formatting.test.ts +107 -0
  160. package/src/conditional-formatting.ts +239 -0
  161. package/src/core.coverage.test.ts +230 -0
  162. package/src/core.performance.test.ts +30 -0
  163. package/src/core.ts +991 -0
  164. package/src/createGrid.svelte.ts +42 -0
  165. package/src/createGrid.test.ts +10 -0
  166. package/src/createGridState.svelte.ts +17 -0
  167. package/src/editors/cell-editors.test.ts +80 -0
  168. package/src/editors/cell-editors.ts +127 -0
  169. package/src/fill-patterns.test.ts +93 -0
  170. package/src/fill-patterns.ts +229 -0
  171. package/src/filtering/excel-filters.test.ts +131 -0
  172. package/src/filtering/excel-filters.ts +101 -0
  173. package/src/filtering/locale-filter.test.ts +220 -0
  174. package/src/flex-render.test.ts +143 -0
  175. package/src/flex-render.ts +3 -0
  176. package/src/group-aggregate.test.ts +48 -0
  177. package/src/index.ts +159 -0
  178. package/src/keyboard.test.ts +59 -0
  179. package/src/keyboard.ts +97 -0
  180. package/src/merge-objects.ts +48 -0
  181. package/src/named-views.test.ts +66 -0
  182. package/src/named-views.ts +120 -0
  183. package/src/new-features.test.ts +217 -0
  184. package/src/render-component.test.ts +51 -0
  185. package/src/render-component.ts +28 -0
  186. package/src/server-data-source.test.ts +88 -0
  187. package/src/server-data-source.ts +163 -0
  188. package/src/sparkline.test.ts +59 -0
  189. package/src/sparkline.ts +164 -0
  190. package/src/static-functions.ts +11 -0
  191. package/src/subscribe.test.ts +103 -0
  192. package/src/subscribe.ts +38 -0
  193. package/src/sv-grid-scrollbar.ts +347 -0
  194. package/src/svgrid-wrapper.types.ts +382 -0
  195. package/src/svgrid.api-extensions.test.ts +362 -0
  196. package/src/svgrid.api.test.ts +192 -0
  197. package/src/svgrid.behavior.test.ts +657 -0
  198. package/src/svgrid.column-reorder.test.ts +234 -0
  199. package/src/svgrid.features.test.ts +157 -0
  200. package/src/svgrid.interaction.test.ts +355 -0
  201. package/src/svgrid.locale-filtering.test.ts +259 -0
  202. package/src/svgrid.new-features.wrapper.test.ts +164 -0
  203. package/src/svgrid.row-pinning.test.ts +266 -0
  204. package/src/svgrid.set-filter.test.ts +270 -0
  205. package/src/svgrid.wrapper.test.ts +35 -0
  206. package/src/test-setup.ts +37 -0
  207. package/src/transaction.test.ts +100 -0
  208. package/src/virtualization/column-virtualizer.test.ts +27 -0
  209. package/src/virtualization/column-virtualizer.ts +30 -0
  210. package/src/virtualization/svelte-virtualizer.svelte.ts +24 -0
  211. package/src/virtualization/types.ts +30 -0
  212. package/src/virtualization/virtualizer.test.ts +47 -0
  213. package/src/virtualization/virtualizer.ts +270 -0
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Unit tests for applyExcelFilter. Every operator + edge case.
3
+ */
4
+ import { describe, expect, it } from 'vitest'
5
+ import { applyExcelFilter, type ExcelFilter } from './excel-filters'
6
+
7
+ const make = (
8
+ operator: ExcelFilter['operator'],
9
+ value?: unknown,
10
+ valueTo?: unknown,
11
+ ): ExcelFilter => ({
12
+ id: 'col',
13
+ operator,
14
+ value,
15
+ valueTo,
16
+ })
17
+
18
+ describe('applyExcelFilter - basic operators (smoke regression)', () => {
19
+ it('supports text operators (contains, startsWith, equals)', () => {
20
+ expect(applyExcelFilter('alphabet', make('contains', 'pha'))).toBe(true)
21
+ expect(applyExcelFilter('alphabet', make('startsWith', 'alp'))).toBe(true)
22
+ expect(applyExcelFilter('alphabet', make('equals', 'alphabet'))).toBe(true)
23
+ })
24
+
25
+ it('supports numeric operators (greaterThan, between)', () => {
26
+ expect(applyExcelFilter(15, make('greaterThan', 10))).toBe(true)
27
+ expect(applyExcelFilter(15, make('between', 10, 20))).toBe(true)
28
+ })
29
+
30
+ it('supports blank checks', () => {
31
+ expect(applyExcelFilter('', make('isBlank'))).toBe(true)
32
+ expect(applyExcelFilter('x', make('isBlank'))).toBe(false)
33
+ })
34
+ })
35
+
36
+ describe('applyExcelFilter - contains', () => {
37
+ it('matches case-insensitive substrings', () => {
38
+ expect(applyExcelFilter('Hello World', make('contains', 'world'))).toBe(true)
39
+ expect(applyExcelFilter('Hello World', make('contains', 'World'))).toBe(true)
40
+ })
41
+ it('returns false when substring absent', () => {
42
+ expect(applyExcelFilter('Hello', make('contains', 'xyz'))).toBe(false)
43
+ })
44
+ it('treats empty filter value as universal match', () => {
45
+ expect(applyExcelFilter('anything', make('contains', ''))).toBe(true)
46
+ })
47
+ it('coerces null cell to empty string', () => {
48
+ expect(applyExcelFilter(null, make('contains', 'x'))).toBe(false)
49
+ expect(applyExcelFilter(null, make('contains', ''))).toBe(true)
50
+ })
51
+ })
52
+
53
+ describe('applyExcelFilter - equals', () => {
54
+ it('matches case-insensitively', () => {
55
+ expect(applyExcelFilter('Boston', make('equals', 'boston'))).toBe(true)
56
+ })
57
+ it('rejects partial matches', () => {
58
+ expect(applyExcelFilter('Boston', make('equals', 'bost'))).toBe(false)
59
+ })
60
+ it('treats null as empty', () => {
61
+ expect(applyExcelFilter(null, make('equals', ''))).toBe(true)
62
+ })
63
+ })
64
+
65
+ describe('applyExcelFilter - startsWith', () => {
66
+ it('matches a prefix case-insensitively', () => {
67
+ expect(applyExcelFilter('Brooklyn', make('startsWith', 'brook'))).toBe(true)
68
+ expect(applyExcelFilter('Brooklyn', make('startsWith', 'lyn'))).toBe(false)
69
+ })
70
+ })
71
+
72
+ describe('applyExcelFilter - greaterThan', () => {
73
+ it('compares numerically when both sides parse as numbers', () => {
74
+ expect(applyExcelFilter(100, make('greaterThan', 99))).toBe(true)
75
+ expect(applyExcelFilter('100', make('greaterThan', 50))).toBe(true)
76
+ expect(applyExcelFilter(50, make('greaterThan', 50))).toBe(false)
77
+ })
78
+ it('falls back to string comparison when either side is non-numeric', () => {
79
+ expect(applyExcelFilter('banana', make('greaterThan', 'apple'))).toBe(true)
80
+ expect(applyExcelFilter('apple', make('greaterThan', 'banana'))).toBe(false)
81
+ })
82
+ it('treats null on either side as empty string in the fallback', () => {
83
+ expect(applyExcelFilter(null, make('greaterThan', 'a'))).toBe(false)
84
+ expect(applyExcelFilter('a', make('greaterThan', null))).toBe(true)
85
+ })
86
+ })
87
+
88
+ describe('applyExcelFilter - lessThan', () => {
89
+ it('compares numerically when both sides parse', () => {
90
+ expect(applyExcelFilter(10, make('lessThan', 20))).toBe(true)
91
+ expect(applyExcelFilter(20, make('lessThan', 10))).toBe(false)
92
+ })
93
+ it('falls back to string comparison', () => {
94
+ expect(applyExcelFilter('apple', make('lessThan', 'banana'))).toBe(true)
95
+ })
96
+ it('treats nulls as empty strings in fallback', () => {
97
+ expect(applyExcelFilter(null, make('lessThan', 'a'))).toBe(true)
98
+ })
99
+ })
100
+
101
+ describe('applyExcelFilter - between', () => {
102
+ it('includes both endpoints', () => {
103
+ expect(applyExcelFilter(5, make('between', 1, 10))).toBe(true)
104
+ expect(applyExcelFilter(1, make('between', 1, 10))).toBe(true)
105
+ expect(applyExcelFilter(10, make('between', 1, 10))).toBe(true)
106
+ })
107
+ it('excludes values outside the range', () => {
108
+ expect(applyExcelFilter(0, make('between', 1, 10))).toBe(false)
109
+ expect(applyExcelFilter(11, make('between', 1, 10))).toBe(false)
110
+ })
111
+ it('treats undefined endpoints as 0', () => {
112
+ expect(applyExcelFilter(0, make('between'))).toBe(true)
113
+ expect(applyExcelFilter(1, make('between'))).toBe(false)
114
+ })
115
+ it('treats null cell as 0', () => {
116
+ expect(applyExcelFilter(null, make('between', -1, 1))).toBe(true)
117
+ })
118
+ })
119
+
120
+ describe('applyExcelFilter - isBlank', () => {
121
+ it('matches null, undefined, and whitespace-only strings', () => {
122
+ expect(applyExcelFilter(null, make('isBlank'))).toBe(true)
123
+ expect(applyExcelFilter(undefined, make('isBlank'))).toBe(true)
124
+ expect(applyExcelFilter('', make('isBlank'))).toBe(true)
125
+ expect(applyExcelFilter(' ', make('isBlank'))).toBe(true)
126
+ })
127
+ it('rejects non-blank content', () => {
128
+ expect(applyExcelFilter('x', make('isBlank'))).toBe(false)
129
+ expect(applyExcelFilter(0, make('isBlank'))).toBe(false)
130
+ })
131
+ })
@@ -0,0 +1,101 @@
1
+ export type ExcelFilterOperator =
2
+ | 'contains'
3
+ | 'equals'
4
+ | 'startsWith'
5
+ | 'greaterThan'
6
+ | 'lessThan'
7
+ | 'between'
8
+ | 'isBlank'
9
+
10
+ export type ExcelFilter = {
11
+ id: string
12
+ operator: ExcelFilterOperator
13
+ value?: unknown
14
+ valueTo?: unknown
15
+ }
16
+
17
+ export type ExcelFilterOptions = {
18
+ /**
19
+ * BCP-47 locale tag (or fallback list) for accent- and case-
20
+ * insensitive text comparison. When set, "café", "Café" and "CAFÉ"
21
+ * all match "cafe" without strain.
22
+ *
23
+ * Implementation: NFD-decompose both sides, strip combining marks
24
+ * (diacritics), then locale-aware lowercase. This is the standard
25
+ * "lowercase + asciifolding" pipeline used by Elasticsearch, Postgres
26
+ * (with `unaccent`), and Lucene. Cheap, deterministic, no Collator
27
+ * round-trip.
28
+ */
29
+ locale?: string | ReadonlyArray<string>
30
+ }
31
+
32
+ // U+0300..U+036F is the Combining Diacritical Marks block. After NFD
33
+ // decomposition accented characters become "base char + combining mark";
34
+ // stripping the marks leaves the bare ASCII base.
35
+ const DIACRITIC_RE = /[̀-ͯ]/g
36
+
37
+ /** NFD-decompose, strip combining marks (diacritics), then locale-aware
38
+ * lowercase. The locale-aware lowercasing handles Turkish dotted-I /
39
+ * dotless-i correctly when the consumer threads `"tr"` through. */
40
+ export function normalizeForFilter(
41
+ s: string,
42
+ locale?: string | ReadonlyArray<string>,
43
+ ): string {
44
+ if (!s) return ''
45
+ const stripped = s.normalize('NFD').replace(DIACRITIC_RE, '')
46
+ return locale
47
+ ? stripped.toLocaleLowerCase(locale as string | string[])
48
+ : stripped.toLowerCase()
49
+ }
50
+
51
+ export function applyExcelFilter(
52
+ cellValue: unknown,
53
+ filter: ExcelFilter,
54
+ options?: ExcelFilterOptions,
55
+ ) {
56
+ const text = String(cellValue ?? '')
57
+ const normalizedText = normalizeForFilter(text, options?.locale)
58
+ const normalizedValue = normalizeForFilter(String(filter.value ?? ''), options?.locale)
59
+ switch (filter.operator) {
60
+ case 'contains':
61
+ return normalizedText.includes(normalizedValue)
62
+ case 'equals':
63
+ return normalizedText === normalizedValue
64
+ case 'startsWith':
65
+ return normalizedText.startsWith(normalizedValue)
66
+ case 'greaterThan': {
67
+ const a = Number(cellValue)
68
+ const b = Number(filter.value)
69
+ if (Number.isFinite(a) && Number.isFinite(b)) return a > b
70
+ return String(cellValue ?? '') > String(filter.value ?? '')
71
+ }
72
+ case 'lessThan': {
73
+ const a = Number(cellValue)
74
+ const b = Number(filter.value)
75
+ if (Number.isFinite(a) && Number.isFinite(b)) return a < b
76
+ return String(cellValue ?? '') < String(filter.value ?? '')
77
+ }
78
+ case 'between': {
79
+ // Two paths: numeric (coerce both endpoints with the historical
80
+ // `?? 0` fallback) and string (used when EITHER endpoint is a
81
+ // non-numeric string like an ISO date). Numeric wins when its
82
+ // operands are finite so callers passing numeric ages, prices, etc.
83
+ // see the historical inclusive-range behaviour.
84
+ const lo = filter.value == null ? 0 : Number(filter.value)
85
+ const hi = filter.valueTo == null ? 0 : Number(filter.valueTo)
86
+ const a = Number(cellValue ?? 0)
87
+ if (Number.isFinite(a) && Number.isFinite(lo) && Number.isFinite(hi)) {
88
+ return a >= lo && a <= hi
89
+ }
90
+ // Either endpoint is non-numeric (ISO date string, etc). Compare
91
+ // lexicographically - YYYY-MM-DD orders chronologically so the
92
+ // result matches user intent.
93
+ const s = String(cellValue ?? '')
94
+ const sl = String(filter.value ?? '')
95
+ const sh = String(filter.valueTo ?? '')
96
+ return s >= sl && s <= sh
97
+ }
98
+ case 'isBlank':
99
+ return text.trim().length === 0
100
+ }
101
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Pure unit tests for the locale-aware text normalization + Excel
3
+ * filter pipeline. No DOM, no mounted grid - just the two exported
4
+ * helpers `normalizeForFilter` and `applyExcelFilter`.
5
+ *
6
+ * Covers:
7
+ * - Diacritic stripping (Café → cafe)
8
+ * - Composed / decomposed Unicode round-trip
9
+ * - Locale-specific lowercasing (Turkish dotted-I)
10
+ * - All Excel operators × locale option × accented inputs
11
+ * - Edge cases: empty strings, null/undefined, numeric mixed with text
12
+ */
13
+
14
+ import { describe, expect, it } from 'vitest'
15
+ import {
16
+ applyExcelFilter,
17
+ normalizeForFilter,
18
+ } from './excel-filters'
19
+
20
+ describe('normalizeForFilter - diacritic stripping', () => {
21
+ it('strips Latin accents (NFD path)', () => {
22
+ expect(normalizeForFilter('Café')).toBe('cafe')
23
+ expect(normalizeForFilter('CAFÉ')).toBe('cafe')
24
+ expect(normalizeForFilter('München')).toBe('munchen')
25
+ expect(normalizeForFilter('São Paulo')).toBe('sao paulo')
26
+ expect(normalizeForFilter('Tōkyō')).toBe('tokyo')
27
+ expect(normalizeForFilter('Genève')).toBe('geneve')
28
+ expect(normalizeForFilter('Québec')).toBe('quebec')
29
+ expect(normalizeForFilter('Málaga')).toBe('malaga')
30
+ expect(normalizeForFilter('Brasília')).toBe('brasilia')
31
+ })
32
+
33
+ it('handles already-decomposed input (NFD-stable)', () => {
34
+ // "Café" composed (4 code points) vs decomposed (5 code points).
35
+ const composed = 'Café'.normalize('NFC')
36
+ const decomposed = 'Café'.normalize('NFD')
37
+ expect(normalizeForFilter(composed)).toBe('cafe')
38
+ expect(normalizeForFilter(decomposed)).toBe('cafe')
39
+ })
40
+
41
+ it('preserves base ASCII unchanged', () => {
42
+ expect(normalizeForFilter('Hello World')).toBe('hello world')
43
+ expect(normalizeForFilter('ABC-123')).toBe('abc-123')
44
+ expect(normalizeForFilter('email@example.com')).toBe('email@example.com')
45
+ })
46
+
47
+ it('returns empty string for empty / falsy input', () => {
48
+ expect(normalizeForFilter('')).toBe('')
49
+ })
50
+
51
+ it('keeps non-Latin alphabets intact', () => {
52
+ // The diacritic strip only touches the Combining Diacritical Marks
53
+ // block (U+0300..U+036F). CJK / Cyrillic / Arabic stay as-is.
54
+ expect(normalizeForFilter('東京')).toBe('東京')
55
+ expect(normalizeForFilter('Москва')).toBe('москва')
56
+ expect(normalizeForFilter('القاهرة')).toBe('القاهرة')
57
+ })
58
+ })
59
+
60
+ describe('normalizeForFilter - locale-aware lowercase', () => {
61
+ it('default (no locale) makes English "istanbul" match dotted Turkish "İstanbul"', () => {
62
+ // No locale: NFD decomposes İ → I + combining dot above; the dot is
63
+ // stripped; remaining 'I' becomes 'i' under default lowercase.
64
+ expect(normalizeForFilter('İstanbul')).toBe('istanbul')
65
+ expect(normalizeForFilter('istanbul')).toBe('istanbul')
66
+ })
67
+
68
+ it('Turkish locale honors dotted-I / dotless-i asymmetry', () => {
69
+ // With "tr-TR" the bare 'I' (post-dot-strip) lowercases to dotless
70
+ // 'ı'. This is intentional Turkish behavior and is why we expose a
71
+ // locale prop: consumers who explicitly want Turkish casing get it.
72
+ // English-typed "istanbul" then no longer matches Turkish "İstanbul"
73
+ // - locale-pickers should make that trade-off knowingly.
74
+ expect(normalizeForFilter('İSTANBUL', 'tr-TR')).toBe('ıstanbul')
75
+ expect(normalizeForFilter('I', 'tr-TR')).toBe('ı')
76
+ })
77
+
78
+ it('default (no locale) behaves like toLowerCase()', () => {
79
+ expect(normalizeForFilter('HELLO')).toBe('hello')
80
+ expect(normalizeForFilter('CafÉ')).toBe('cafe') // É
81
+ })
82
+
83
+ it('accepts an array of fallback locales', () => {
84
+ expect(normalizeForFilter('Café', ['fr-CA', 'en-US'])).toBe('cafe')
85
+ })
86
+
87
+ it('Greek / Cyrillic / German all fold cleanly', () => {
88
+ // Greek: stripped tones (oxia)
89
+ expect(normalizeForFilter('Αθήνα')).toBe('αθηνα')
90
+ // German: umlauts strip
91
+ expect(normalizeForFilter('Düsseldorf')).toBe('dusseldorf')
92
+ // Cyrillic: no diacritics, just locale-aware lowercase
93
+ expect(normalizeForFilter('Москва', 'ru-RU')).toBe('москва')
94
+ })
95
+ })
96
+
97
+ describe('applyExcelFilter - contains (locale-aware)', () => {
98
+ it('matches case + accent insensitively without a locale', () => {
99
+ const ok = applyExcelFilter('Café Genève', {
100
+ id: 'name', operator: 'contains', value: 'cafe geneve',
101
+ })
102
+ expect(ok).toBe(true)
103
+ })
104
+
105
+ it('matches with a German locale', () => {
106
+ const ok = applyExcelFilter('München', {
107
+ id: 'city', operator: 'contains', value: 'munch',
108
+ }, { locale: 'de-DE' })
109
+ expect(ok).toBe(true)
110
+ })
111
+
112
+ it('matches with a French locale', () => {
113
+ const ok = applyExcelFilter('Montréal', {
114
+ id: 'city', operator: 'contains', value: 'montreal',
115
+ }, { locale: 'fr-CA' })
116
+ expect(ok).toBe(true)
117
+ })
118
+
119
+ it('matches with a Portuguese locale', () => {
120
+ const ok = applyExcelFilter('São Paulo', {
121
+ id: 'city', operator: 'contains', value: 'sao paulo',
122
+ }, { locale: 'pt-BR' })
123
+ expect(ok).toBe(true)
124
+ })
125
+
126
+ it('rejects non-matching queries even with locale', () => {
127
+ const ok = applyExcelFilter('München', {
128
+ id: 'city', operator: 'contains', value: 'berlin',
129
+ }, { locale: 'de-DE' })
130
+ expect(ok).toBe(false)
131
+ })
132
+
133
+ it('empty needle matches every cell (includes "")', () => {
134
+ expect(applyExcelFilter('München', {
135
+ id: 'city', operator: 'contains', value: '',
136
+ })).toBe(true)
137
+ })
138
+ })
139
+
140
+ describe('applyExcelFilter - equals (locale-aware)', () => {
141
+ it('case + accent insensitive equals', () => {
142
+ expect(applyExcelFilter('Café', { id: 'x', operator: 'equals', value: 'CAFE' })).toBe(true)
143
+ expect(applyExcelFilter('Café', { id: 'x', operator: 'equals', value: 'cafe' })).toBe(true)
144
+ expect(applyExcelFilter('Café', { id: 'x', operator: 'equals', value: 'Café' })).toBe(true)
145
+ })
146
+
147
+ it('rejects when normalized strings differ', () => {
148
+ expect(applyExcelFilter('Café', { id: 'x', operator: 'equals', value: 'cafes' })).toBe(false)
149
+ expect(applyExcelFilter('Café', { id: 'x', operator: 'equals', value: 'café!' })).toBe(false)
150
+ })
151
+ })
152
+
153
+ describe('applyExcelFilter - startsWith (locale-aware)', () => {
154
+ it('matches stripped prefix', () => {
155
+ expect(applyExcelFilter('München-Schwabing', {
156
+ id: 'x', operator: 'startsWith', value: 'munchen',
157
+ })).toBe(true)
158
+ })
159
+
160
+ it('rejects mid-string match', () => {
161
+ expect(applyExcelFilter('Großmünchen', {
162
+ id: 'x', operator: 'startsWith', value: 'munchen',
163
+ })).toBe(false)
164
+ })
165
+ })
166
+
167
+ describe('applyExcelFilter - numeric operators (locale-independent)', () => {
168
+ it('greaterThan / lessThan / between still work', () => {
169
+ expect(applyExcelFilter(150, { id: 'price', operator: 'greaterThan', value: 100 })).toBe(true)
170
+ expect(applyExcelFilter(150, { id: 'price', operator: 'lessThan', value: 200 })).toBe(true)
171
+ expect(applyExcelFilter(150, {
172
+ id: 'price', operator: 'between', value: 100, valueTo: 200,
173
+ })).toBe(true)
174
+ })
175
+
176
+ it('between rejects out-of-range', () => {
177
+ expect(applyExcelFilter(50, {
178
+ id: 'price', operator: 'between', value: 100, valueTo: 200,
179
+ })).toBe(false)
180
+ })
181
+ })
182
+
183
+ describe('applyExcelFilter - isBlank', () => {
184
+ it('treats empty / whitespace-only as blank', () => {
185
+ expect(applyExcelFilter('', { id: 'x', operator: 'isBlank' })).toBe(true)
186
+ expect(applyExcelFilter(' ', { id: 'x', operator: 'isBlank' })).toBe(true)
187
+ expect(applyExcelFilter(null, { id: 'x', operator: 'isBlank' })).toBe(true)
188
+ expect(applyExcelFilter(undefined, { id: 'x', operator: 'isBlank' })).toBe(true)
189
+ })
190
+
191
+ it('rejects non-blank cells', () => {
192
+ expect(applyExcelFilter('Café', { id: 'x', operator: 'isBlank' })).toBe(false)
193
+ expect(applyExcelFilter(' x ', { id: 'x', operator: 'isBlank' })).toBe(false)
194
+ })
195
+ })
196
+
197
+ describe('applyExcelFilter - regression: pre-locale behavior preserved', () => {
198
+ // Before locale support, ASCII case-insensitive matching was the rule.
199
+ // Verify nothing regressed for the common path.
200
+ it('plain ASCII contains', () => {
201
+ expect(applyExcelFilter('Ada Lovelace', {
202
+ id: 'name', operator: 'contains', value: 'lovelace',
203
+ })).toBe(true)
204
+ expect(applyExcelFilter('Ada Lovelace', {
205
+ id: 'name', operator: 'contains', value: 'LOVELACE',
206
+ })).toBe(true)
207
+ })
208
+
209
+ it('plain ASCII equals', () => {
210
+ expect(applyExcelFilter('active', {
211
+ id: 'status', operator: 'equals', value: 'ACTIVE',
212
+ })).toBe(true)
213
+ })
214
+
215
+ it('plain ASCII startsWith', () => {
216
+ expect(applyExcelFilter('Linus Torvalds', {
217
+ id: 'name', operator: 'startsWith', value: 'linus',
218
+ })).toBe(true)
219
+ })
220
+ })
@@ -0,0 +1,143 @@
1
+ /**
2
+ * FlexRender tests. The component routes one of {content+context, cell,
3
+ * header, footer} into the right render path: string, RenderComponentConfig,
4
+ * RenderSnippetConfig, or raw value.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { mount, unmount } from 'svelte'
8
+ import FlexRender from './FlexRender.svelte'
9
+ import { RenderComponentConfig, RenderSnippetConfig } from './render-component'
10
+
11
+ function mountFlex(props: any) {
12
+ const target = document.createElement('div')
13
+ document.body.appendChild(target)
14
+ const app = mount(FlexRender, { target, props })
15
+ return {
16
+ target,
17
+ destroy: () => {
18
+ unmount(app)
19
+ target.remove()
20
+ },
21
+ }
22
+ }
23
+
24
+ describe('FlexRender', () => {
25
+ it('renders a plain string content directly', () => {
26
+ const { target, destroy } = mountFlex({
27
+ content: 'Hello header',
28
+ context: { column: {}, table: {} } as any,
29
+ })
30
+ try {
31
+ expect(target.textContent).toContain('Hello header')
32
+ } finally {
33
+ destroy()
34
+ }
35
+ })
36
+
37
+ it('calls a function content with the context and renders the result', () => {
38
+ const fn = (ctx: any) => `value=${ctx.value}`
39
+ const { target, destroy } = mountFlex({
40
+ content: fn,
41
+ context: { value: 42 } as any,
42
+ })
43
+ try {
44
+ expect(target.textContent).toContain('value=42')
45
+ } finally {
46
+ destroy()
47
+ }
48
+ })
49
+
50
+ it('renders nothing when content is undefined', () => {
51
+ const { target, destroy } = mountFlex({
52
+ content: undefined,
53
+ context: {} as any,
54
+ })
55
+ try {
56
+ expect(target.textContent?.trim()).toBe('')
57
+ } finally {
58
+ destroy()
59
+ }
60
+ })
61
+
62
+ it('pulls content + context from `cell` when provided', () => {
63
+ const cell = {
64
+ column: { columnDef: { cell: (ctx: any) => `row=${ctx.row.id}` } },
65
+ getContext: () => ({ row: { id: 7 } }),
66
+ }
67
+ const { target, destroy } = mountFlex({ cell } as any)
68
+ try {
69
+ expect(target.textContent).toContain('row=7')
70
+ } finally {
71
+ destroy()
72
+ }
73
+ })
74
+
75
+ it('pulls content + context from `header` when provided', () => {
76
+ const header = {
77
+ column: { columnDef: { header: 'Total' } },
78
+ getContext: () => ({}),
79
+ }
80
+ const { target, destroy } = mountFlex({ header } as any)
81
+ try {
82
+ expect(target.textContent).toContain('Total')
83
+ } finally {
84
+ destroy()
85
+ }
86
+ })
87
+
88
+ it('pulls content + context from `footer` when provided', () => {
89
+ const footer = {
90
+ column: { columnDef: { footer: 'Sum' } },
91
+ getContext: () => ({}),
92
+ }
93
+ const { target, destroy } = mountFlex({ footer } as any)
94
+ try {
95
+ expect(target.textContent).toContain('Sum')
96
+ } finally {
97
+ destroy()
98
+ }
99
+ })
100
+
101
+ it('renders a RenderComponentConfig result (smoke)', () => {
102
+ // Anonymous Svelte-like component would require compilation; we just
103
+ // exercise the constructor path so the discriminator branch executes.
104
+ const fn = () => new RenderComponentConfig(null as any, {} as any)
105
+ const { destroy } = mountFlex({
106
+ content: fn,
107
+ context: {} as any,
108
+ })
109
+ try {
110
+ // No throw means the discriminator was hit.
111
+ expect(true).toBe(true)
112
+ } finally {
113
+ destroy()
114
+ }
115
+ })
116
+
117
+ it('renders a RenderSnippetConfig result (smoke)', () => {
118
+ const fakeSnippet = (() => null) as any
119
+ const fn = () => new RenderSnippetConfig(fakeSnippet, undefined as any)
120
+ const { destroy } = mountFlex({
121
+ content: fn,
122
+ context: {} as any,
123
+ })
124
+ try {
125
+ expect(true).toBe(true)
126
+ } finally {
127
+ destroy()
128
+ }
129
+ })
130
+
131
+ it('renders a raw scalar value when the function returns one', () => {
132
+ const fn = () => 99
133
+ const { target, destroy } = mountFlex({
134
+ content: fn,
135
+ context: {} as any,
136
+ })
137
+ try {
138
+ expect(target.textContent).toContain('99')
139
+ } finally {
140
+ destroy()
141
+ }
142
+ })
143
+ })
@@ -0,0 +1,3 @@
1
+ /// <reference types="svelte" />
2
+
3
+ export { default as FlexRender } from './FlexRender.svelte'
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { applyGroupAggregate } from './core'
3
+ import type { Row } from './core'
4
+
5
+ type R = { v: number; cat: string }
6
+
7
+ // Minimal Row stubs - applyGroupAggregate only touches getCellValueByColumnId
8
+ // and original.
9
+ function rows(values: number[]): Row<R>[] {
10
+ return values.map(
11
+ (v) =>
12
+ ({
13
+ original: { v, cat: 'x' } as R,
14
+ getCellValueByColumnId: (id: string) => (id === 'v' ? v : 'x'),
15
+ }) as unknown as Row<R>,
16
+ )
17
+ }
18
+
19
+ describe('applyGroupAggregate', () => {
20
+ const r = rows([10, 20, 30])
21
+
22
+ it('sum / avg / min / max', () => {
23
+ expect(applyGroupAggregate('sum', 'v', r)).toBe(60)
24
+ expect(applyGroupAggregate('avg', 'v', r)).toBe(20)
25
+ expect(applyGroupAggregate('min', 'v', r)).toBe(10)
26
+ expect(applyGroupAggregate('max', 'v', r)).toBe(30)
27
+ })
28
+
29
+ it('count / countDistinct / first / extent', () => {
30
+ expect(applyGroupAggregate('count', 'v', r)).toBe(3)
31
+ expect(applyGroupAggregate('countDistinct', 'cat', r)).toBe(1)
32
+ expect(applyGroupAggregate('first', 'v', r)).toBe(10)
33
+ expect(applyGroupAggregate('extent', 'v', r)).toBe('10 – 30')
34
+ })
35
+
36
+ it('custom function receives numeric values + rows', () => {
37
+ const median = applyGroupAggregate(
38
+ (vals) => vals.sort((a, b) => a - b)[Math.floor(vals.length / 2)],
39
+ 'v',
40
+ r,
41
+ )
42
+ expect(median).toBe(20)
43
+ })
44
+
45
+ it('numeric reducers ignore non-numeric and empty groups', () => {
46
+ expect(applyGroupAggregate('sum', 'v', rows([]))).toBeUndefined()
47
+ })
48
+ })