@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,74 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import {
3
+ createCollaboration,
4
+ type CollabMessage,
5
+ type CollabTransport,
6
+ } from './collaboration'
7
+
8
+ /** An in-memory bus so two controllers can talk in a test. */
9
+ function makeBus() {
10
+ const handlers = new Set<(m: CollabMessage) => void>()
11
+ const transportFor = (): CollabTransport => ({
12
+ post: (msg) => {
13
+ // deliver async-ish to every subscriber except none (real channels echo
14
+ // to other tabs, not self; our controllers ignore their own id anyway).
15
+ for (const h of [...handlers]) h(msg)
16
+ },
17
+ subscribe: (h) => {
18
+ handlers.add(h)
19
+ return () => handlers.delete(h)
20
+ },
21
+ })
22
+ return { transportFor }
23
+ }
24
+
25
+ const userA = { id: 'a', name: 'Ada', color: '#f00' }
26
+ const userB = { id: 'b', name: 'Bob', color: '#00f' }
27
+
28
+ describe('createCollaboration', () => {
29
+ it('peers see each other after hello', () => {
30
+ const bus = makeBus()
31
+ const a = createCollaboration({ user: userA, transport: bus.transportFor() })
32
+ const b = createCollaboration({ user: userB, transport: bus.transportFor() })
33
+ // A is announced; B's hello reaches A; A replies presence.
34
+ expect(a.peers().map((p) => p.id)).toEqual(['b'])
35
+ expect(b.peers().map((p) => p.id)).toEqual(['a'])
36
+ a.dispose()
37
+ b.dispose()
38
+ })
39
+
40
+ it('setCell propagates a remote cursor', () => {
41
+ const bus = makeBus()
42
+ const onPeers = vi.fn()
43
+ const a = createCollaboration({ user: userA, transport: bus.transportFor(), onPeersChange: onPeers })
44
+ const b = createCollaboration({ user: userB, transport: bus.transportFor() })
45
+ b.setCell({ rowId: 'r1', columnId: 'salary' })
46
+ const peerB = a.peers().find((p) => p.id === 'b')
47
+ expect(peerB?.cell).toEqual({ rowId: 'r1', columnId: 'salary' })
48
+ a.dispose()
49
+ b.dispose()
50
+ })
51
+
52
+ it('sendEdit fires onRemoteEdit on the other peer only', () => {
53
+ const bus = makeBus()
54
+ const aEdits = vi.fn()
55
+ const bEdits = vi.fn()
56
+ const a = createCollaboration({ user: userA, transport: bus.transportFor(), onRemoteEdit: aEdits })
57
+ const b = createCollaboration({ user: userB, transport: bus.transportFor(), onRemoteEdit: bEdits })
58
+ a.sendEdit('r2', 'name', 'Grace')
59
+ expect(bEdits).toHaveBeenCalledWith(expect.objectContaining({ rowId: 'r2', columnId: 'name', value: 'Grace' }))
60
+ expect(aEdits).not.toHaveBeenCalled() // not echoed to self
61
+ a.dispose()
62
+ b.dispose()
63
+ })
64
+
65
+ it('bye removes a peer', () => {
66
+ const bus = makeBus()
67
+ const a = createCollaboration({ user: userA, transport: bus.transportFor() })
68
+ const b = createCollaboration({ user: userB, transport: bus.transportFor() })
69
+ expect(a.peers()).toHaveLength(1)
70
+ b.dispose()
71
+ expect(a.peers()).toHaveLength(0)
72
+ a.dispose()
73
+ })
74
+ })
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Real-time collaboration. Presence (who else is here + where their cursor
3
+ * is) and live edits (a change in one client lands in every other), over a
4
+ * pluggable transport. The transport is the only thing tied to infrastructure
5
+ * - swap `broadcastChannelTransport` (same-browser tabs, zero backend) for a
6
+ * WebSocket / WebRTC / CRDT adapter and the controller is unchanged.
7
+ *
8
+ * This is also the natural "multiple agents on one grid" substrate: an AI
9
+ * agent is just another peer posting `edit` messages.
10
+ */
11
+ export type CollabUser = {
12
+ id: string
13
+ name: string
14
+ /** A CSS color for this user's cursor / avatar. */
15
+ color: string
16
+ }
17
+
18
+ export type CollabCell = { rowId: string; columnId: string }
19
+
20
+ export type CollabPresence = CollabUser & {
21
+ cell: CollabCell | null
22
+ /** Last time we heard from this peer (ms). Used to prune the gone. */
23
+ ts: number
24
+ }
25
+
26
+ export type CollabMessage =
27
+ | { kind: 'hello'; user: CollabUser }
28
+ | { kind: 'presence'; user: CollabUser; cell: CollabCell | null }
29
+ | { kind: 'edit'; user: CollabUser; rowId: string; columnId: string; value: unknown }
30
+ | { kind: 'bye'; userId: string }
31
+
32
+ export type CollabTransport = {
33
+ post(msg: CollabMessage): void
34
+ subscribe(handler: (msg: CollabMessage) => void): () => void
35
+ }
36
+
37
+ export type Collaboration = {
38
+ /** Broadcast where this user's cursor is (or null when it leaves). */
39
+ setCell(cell: CollabCell | null): void
40
+ /** Broadcast a cell edit to every peer. */
41
+ sendEdit(rowId: string, columnId: string, value: unknown): void
42
+ /** The peers currently present (excludes self). */
43
+ peers(): CollabPresence[]
44
+ dispose(): void
45
+ }
46
+
47
+ export type CollaborationOptions = {
48
+ user: CollabUser
49
+ transport: CollabTransport
50
+ /** Fired (with self excluded) whenever the peer set or a cursor changes. */
51
+ onPeersChange?: (peers: CollabPresence[]) => void
52
+ /** Fired when another user edits a cell - apply it to your data. */
53
+ onRemoteEdit?: (edit: {
54
+ rowId: string
55
+ columnId: string
56
+ value: unknown
57
+ user: CollabUser
58
+ }) => void
59
+ /** Drop peers we haven't heard from in this many ms. Default 15000. */
60
+ peerTimeoutMs?: number
61
+ }
62
+
63
+ /** BroadcastChannel transport - live across tabs of the same browser, no
64
+ * backend. No-ops where BroadcastChannel is unavailable (SSR / old env). */
65
+ export function broadcastChannelTransport(name: string): CollabTransport {
66
+ const available = typeof BroadcastChannel !== 'undefined'
67
+ const channel = available ? new BroadcastChannel(name) : null
68
+ return {
69
+ post(msg) {
70
+ channel?.postMessage(msg)
71
+ },
72
+ subscribe(handler) {
73
+ if (!channel) return () => {}
74
+ const listener = (e: MessageEvent) => handler(e.data as CollabMessage)
75
+ channel.addEventListener('message', listener)
76
+ return () => channel.removeEventListener('message', listener)
77
+ },
78
+ }
79
+ }
80
+
81
+ export function createCollaboration(options: CollaborationOptions): Collaboration {
82
+ const { user, transport } = options
83
+ const timeout = options.peerTimeoutMs ?? 15_000
84
+ const peers = new Map<string, CollabPresence>()
85
+ let ownCell: CollabCell | null = null
86
+ let disposed = false
87
+
88
+ const list = () => [...peers.values()].sort((a, b) => a.name.localeCompare(b.name))
89
+ const notify = () => options.onPeersChange?.(list())
90
+
91
+ function upsert(u: CollabUser, cell: CollabCell | null) {
92
+ if (u.id === user.id) return
93
+ peers.set(u.id, { ...u, cell, ts: Date.now() })
94
+ notify()
95
+ }
96
+
97
+ const unsubscribe = transport.subscribe((msg) => {
98
+ if (disposed) return
99
+ switch (msg.kind) {
100
+ case 'hello':
101
+ upsert(msg.user, null)
102
+ // Let the newcomer know we're here (and where our cursor is).
103
+ transport.post({ kind: 'presence', user, cell: ownCell })
104
+ break
105
+ case 'presence':
106
+ upsert(msg.user, msg.cell)
107
+ break
108
+ case 'edit':
109
+ upsert(msg.user, { rowId: msg.rowId, columnId: msg.columnId })
110
+ if (msg.user.id !== user.id) {
111
+ options.onRemoteEdit?.({
112
+ rowId: msg.rowId,
113
+ columnId: msg.columnId,
114
+ value: msg.value,
115
+ user: msg.user,
116
+ })
117
+ }
118
+ break
119
+ case 'bye':
120
+ if (peers.delete(msg.userId)) notify()
121
+ break
122
+ }
123
+ })
124
+
125
+ // Prune peers that went silent (closed tab without a `bye`).
126
+ const pruneTimer =
127
+ typeof setInterval !== 'undefined'
128
+ ? setInterval(() => {
129
+ const now = Date.now()
130
+ let changed = false
131
+ for (const [id, p] of peers) {
132
+ if (now - p.ts > timeout) {
133
+ peers.delete(id)
134
+ changed = true
135
+ }
136
+ }
137
+ if (changed) notify()
138
+ }, Math.max(1000, timeout / 3))
139
+ : null
140
+
141
+ // Announce ourselves.
142
+ transport.post({ kind: 'hello', user })
143
+
144
+ return {
145
+ setCell(cell) {
146
+ ownCell = cell
147
+ transport.post({ kind: 'presence', user, cell })
148
+ },
149
+ sendEdit(rowId, columnId, value) {
150
+ transport.post({ kind: 'edit', user, rowId, columnId, value })
151
+ },
152
+ peers: list,
153
+ dispose() {
154
+ if (disposed) return
155
+ disposed = true
156
+ transport.post({ kind: 'bye', userId: user.id })
157
+ unsubscribe()
158
+ if (pruneTimer) clearInterval(pruneTimer)
159
+ },
160
+ }
161
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ computeColumnStat,
4
+ lerpColor,
5
+ contrastText,
6
+ resolveCellFormat,
7
+ formatsNeedingStats,
8
+ type ConditionalFormat,
9
+ } from './conditional-formatting'
10
+
11
+ describe('contrastText', () => {
12
+ it('returns dark text on light backgrounds, white on dark', () => {
13
+ expect(contrastText('#fde68a')).toBe('#1e293b') // light yellow
14
+ expect(contrastText('#86efac')).toBe('#1e293b') // light green
15
+ expect(contrastText('#1d4ed8')).toBe('#ffffff') // deep blue
16
+ })
17
+ it('returns null for non-hex backgrounds', () => {
18
+ expect(contrastText('var(--x)')).toBeNull()
19
+ })
20
+ })
21
+
22
+ describe('computeColumnStat', () => {
23
+ it('returns min/max of finite numbers', () => {
24
+ expect(computeColumnStat([3, 1, 'x', 9, null])).toEqual({ min: 1, max: 9 })
25
+ })
26
+ it('returns null when nothing is numeric', () => {
27
+ expect(computeColumnStat(['a', null, undefined])).toBeNull()
28
+ })
29
+ })
30
+
31
+ describe('lerpColor', () => {
32
+ it('interpolates midpoint', () => {
33
+ expect(lerpColor('#000000', '#ffffff', 0.5)).toBe('#808080')
34
+ })
35
+ it('clamps and falls back on bad input', () => {
36
+ expect(lerpColor('#ff0000', '#00ff00', 0)).toBe('#ff0000')
37
+ expect(lerpColor('nope', '#00ff00', 0.5)).toBe('nope')
38
+ })
39
+ })
40
+
41
+ describe('resolveCellFormat', () => {
42
+ const stat = { min: 0, max: 100 }
43
+
44
+ it('color scale maps value position to a gradient color', () => {
45
+ const fmts: ConditionalFormat[] = [
46
+ { type: 'colorScale', min: '#ff0000', max: '#00ff00' },
47
+ ]
48
+ expect(resolveCellFormat(0, {}, 'c', fmts, stat).background).toBe('#ff0000')
49
+ expect(resolveCellFormat(100, {}, 'c', fmts, stat).background).toBe('#00ff00')
50
+ expect(resolveCellFormat(50, {}, 'c', fmts, stat).background).toBe('#808000')
51
+ })
52
+
53
+ it('data bar computes a percent and picks negative color', () => {
54
+ const fmts: ConditionalFormat[] = [
55
+ { type: 'dataBar', color: '#16a34a', negativeColor: '#ef4444' },
56
+ ]
57
+ const pos = resolveCellFormat(50, {}, 'c', fmts, { min: -100, max: 100 })
58
+ expect(pos.dataBar?.percent).toBeCloseTo(75)
59
+ expect(pos.dataBar?.color).toBe('#16a34a')
60
+ const neg = resolveCellFormat(-50, {}, 'c', fmts, { min: -100, max: 100 })
61
+ expect(neg.dataBar?.color).toBe('#ef4444')
62
+ })
63
+
64
+ it('icon set buckets by ascending thresholds', () => {
65
+ const fmts: ConditionalFormat[] = [
66
+ { type: 'iconSet', set: 'arrows', thresholds: [0, 50] },
67
+ ]
68
+ expect(resolveCellFormat(-5, {}, 'c', fmts, null).icon).toBe('↓')
69
+ expect(resolveCellFormat(10, {}, 'c', fmts, null).icon).toBe('→')
70
+ expect(resolveCellFormat(80, {}, 'c', fmts, null).icon).toBe('↑')
71
+ })
72
+
73
+ it('rule applies styles only when the predicate matches', () => {
74
+ const fmts: ConditionalFormat[] = [
75
+ {
76
+ type: 'rule',
77
+ when: ({ value }) => Number(value) < 0,
78
+ background: '#fee2e2',
79
+ color: '#991b1b',
80
+ },
81
+ ]
82
+ expect(resolveCellFormat(-1, {}, 'c', fmts, null).background).toBe('#fee2e2')
83
+ expect(resolveCellFormat(1, {}, 'c', fmts, null).background).toBeUndefined()
84
+ })
85
+
86
+ it('respects the columns scope', () => {
87
+ const fmts: ConditionalFormat[] = [
88
+ { type: 'colorScale', min: '#000000', max: '#ffffff', columns: ['other'] },
89
+ ]
90
+ expect(resolveCellFormat(50, {}, 'c', fmts, stat).background).toBeUndefined()
91
+ })
92
+
93
+ it('later formats override earlier ones', () => {
94
+ const fmts: ConditionalFormat[] = [
95
+ { type: 'rule', when: () => true, background: '#aaa' },
96
+ { type: 'rule', when: () => true, background: '#bbb' },
97
+ ]
98
+ expect(resolveCellFormat(1, {}, 'c', fmts, null).background).toBe('#bbb')
99
+ })
100
+
101
+ it('formatsNeedingStats flags colorScale/dataBar only', () => {
102
+ expect(formatsNeedingStats([{ type: 'iconSet', thresholds: [0] }])).toBe(false)
103
+ expect(
104
+ formatsNeedingStats([{ type: 'dataBar', color: '#000' }]),
105
+ ).toBe(true)
106
+ })
107
+ })
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Conditional formatting engine. Excel-style declarative rules that color a
3
+ * cell by its value - color scales, in-cell data bars, icon sets, and plain
4
+ * predicate rules. This goes beyond `cellClass(ctx)` (which can only toggle
5
+ * static classes) because color scales and data bars need a value computed
6
+ * against the column's min/max range.
7
+ *
8
+ * Everything here is pure: `resolveCellFormat` takes a value + the column's
9
+ * numeric range and returns the visual primitives, so the `<SvGrid>` render
10
+ * component just paints the result and the logic is unit-testable.
11
+ */
12
+
13
+ export type ColorScaleFormat = {
14
+ type: 'colorScale'
15
+ /** 2-stop (min/max) or 3-stop (min/mid/max) gradient. Hex colors. */
16
+ min: string
17
+ mid?: string
18
+ max: string
19
+ /** Fix the scale; otherwise derived from the column's data. */
20
+ minValue?: number
21
+ maxValue?: number
22
+ }
23
+
24
+ export type DataBarFormat = {
25
+ type: 'dataBar'
26
+ color: string
27
+ negativeColor?: string
28
+ minValue?: number
29
+ maxValue?: number
30
+ /** Show the cell's text on top of the bar. Default true. */
31
+ showValue?: boolean
32
+ }
33
+
34
+ export type IconSetName = 'arrows' | 'traffic' | 'triangles'
35
+
36
+ export type IconSetFormat = {
37
+ type: 'iconSet'
38
+ set?: IconSetName
39
+ /** Ascending breakpoints. n thresholds => n+1 buckets/icons. */
40
+ thresholds: number[]
41
+ /** Hide the numeric text, show only the icon. Default false. */
42
+ iconOnly?: boolean
43
+ }
44
+
45
+ export type RuleFormat<TData = unknown> = {
46
+ type: 'rule'
47
+ /** Apply the styles below when this returns true. */
48
+ when: (ctx: { value: unknown; row: TData }) => boolean
49
+ background?: string
50
+ color?: string
51
+ fontWeight?: string | number
52
+ }
53
+
54
+ export type ConditionalFormatSpec<TData = unknown> =
55
+ | ColorScaleFormat
56
+ | DataBarFormat
57
+ | IconSetFormat
58
+ | RuleFormat<TData>
59
+
60
+ /** A format scoped to specific columns (omit `columns` to apply to all). */
61
+ export type ConditionalFormat<TData = unknown> = ConditionalFormatSpec<TData> & {
62
+ columns?: ReadonlyArray<string>
63
+ }
64
+
65
+ export type ResolvedCellFormat = {
66
+ background?: string
67
+ color?: string
68
+ fontWeight?: string | number
69
+ dataBar?: { percent: number; color: string; fromRight: boolean }
70
+ icon?: string
71
+ iconOnly?: boolean
72
+ }
73
+
74
+ export type ColumnStat = { min: number; max: number }
75
+
76
+ const ICON_SETS: Record<IconSetName, string[]> = {
77
+ // ordered low -> high; bucket i picks icon i
78
+ arrows: ['↓', '→', '↑'],
79
+ traffic: ['🔴', '🟡', '🟢'],
80
+ triangles: ['▼', '◆', '▲'],
81
+ }
82
+
83
+ /** Min/max of a column's finite numeric values, or null if none. */
84
+ export function computeColumnStat(values: Iterable<unknown>): ColumnStat | null {
85
+ let min = Number.POSITIVE_INFINITY
86
+ let max = Number.NEGATIVE_INFINITY
87
+ let any = false
88
+ for (const v of values) {
89
+ if (v == null || v === '') continue
90
+ const n = Number(v)
91
+ if (!Number.isFinite(n)) continue
92
+ any = true
93
+ if (n < min) min = n
94
+ if (n > max) max = n
95
+ }
96
+ return any ? { min, max } : null
97
+ }
98
+
99
+ /** Which icon a value lands on, given ascending thresholds. */
100
+ function pickIcon(set: string[], thresholds: number[], n: number): string {
101
+ let bucket = 0
102
+ for (let i = 0; i < thresholds.length; i += 1) {
103
+ if (n >= thresholds[i]!) bucket = i + 1
104
+ }
105
+ // Map the bucket onto the available icons (clamp + spread).
106
+ const idx = Math.min(set.length - 1, Math.max(0, bucket))
107
+ return set[idx] ?? set[set.length - 1]!
108
+ }
109
+
110
+ function clamp01(x: number): number {
111
+ return x < 0 ? 0 : x > 1 ? 1 : x
112
+ }
113
+
114
+ function parseHex(hex: string): [number, number, number] | null {
115
+ const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
116
+ if (!m) return null
117
+ const int = parseInt(m[1]!, 16)
118
+ return [(int >> 16) & 255, (int >> 8) & 255, int & 255]
119
+ }
120
+
121
+ function toHex(rgb: [number, number, number]): string {
122
+ return (
123
+ '#' +
124
+ rgb
125
+ .map((c) => Math.round(clamp01(c / 255) * 255).toString(16).padStart(2, '0'))
126
+ .join('')
127
+ )
128
+ }
129
+
130
+ /**
131
+ * Pick a readable text color (near-black or white) for a given hex
132
+ * background, using WCAG relative luminance. Lets a color-scale fill or a
133
+ * tinted rule keep its text legible without the caller hand-picking a color.
134
+ * Returns null when the background isn't a parseable hex (e.g. a CSS var),
135
+ * so the caller can leave the default text color in place.
136
+ */
137
+ export function contrastText(bg: string): string | null {
138
+ const rgb = parseHex(bg)
139
+ if (!rgb) return null
140
+ const lin = rgb.map((c) => {
141
+ const s = c / 255
142
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
143
+ })
144
+ const L = 0.2126 * lin[0]! + 0.7152 * lin[1]! + 0.0722 * lin[2]!
145
+ return L > 0.45 ? '#1e293b' : '#ffffff'
146
+ }
147
+
148
+ /** Linear interpolate two hex colors. Falls back to `a` if parsing fails. */
149
+ export function lerpColor(a: string, b: string, t: number): string {
150
+ const ca = parseHex(a)
151
+ const cb = parseHex(b)
152
+ if (!ca || !cb) return a
153
+ const k = clamp01(t)
154
+ return toHex([
155
+ ca[0] + (cb[0] - ca[0]) * k,
156
+ ca[1] + (cb[1] - ca[1]) * k,
157
+ ca[2] + (cb[2] - ca[2]) * k,
158
+ ])
159
+ }
160
+
161
+ function colorScaleAt(fmt: ColorScaleFormat, t: number): string {
162
+ if (fmt.mid) {
163
+ return t <= 0.5
164
+ ? lerpColor(fmt.min, fmt.mid, t / 0.5)
165
+ : lerpColor(fmt.mid, fmt.max, (t - 0.5) / 0.5)
166
+ }
167
+ return lerpColor(fmt.min, fmt.max, t)
168
+ }
169
+
170
+ /**
171
+ * Resolve every format that applies to one cell into a single set of visual
172
+ * primitives. Later-listed formats override earlier ones for the same
173
+ * property, so order your `conditionalFormats` from general to specific.
174
+ */
175
+ export function resolveCellFormat<TData = unknown>(
176
+ value: unknown,
177
+ row: TData,
178
+ columnId: string,
179
+ formats: ReadonlyArray<ConditionalFormat<TData>>,
180
+ stat: ColumnStat | null,
181
+ ): ResolvedCellFormat {
182
+ const out: ResolvedCellFormat = {}
183
+ for (const fmt of formats) {
184
+ if (fmt.columns && !fmt.columns.includes(columnId)) continue
185
+ const n = Number(value)
186
+
187
+ if (fmt.type === 'rule') {
188
+ if (fmt.when({ value, row })) {
189
+ if (fmt.background) {
190
+ out.background = fmt.background
191
+ // Auto-pick legible text on the tint unless the rule sets its own.
192
+ if (!fmt.color) {
193
+ const c = contrastText(fmt.background)
194
+ if (c) out.color = c
195
+ }
196
+ }
197
+ if (fmt.color) out.color = fmt.color
198
+ if (fmt.fontWeight != null) out.fontWeight = fmt.fontWeight
199
+ }
200
+ continue
201
+ }
202
+
203
+ if (value == null || value === '' || !Number.isFinite(n)) continue
204
+
205
+ if (fmt.type === 'colorScale') {
206
+ const lo = fmt.minValue ?? stat?.min ?? n
207
+ const hi = fmt.maxValue ?? stat?.max ?? n
208
+ const t = hi === lo ? 0.5 : clamp01((n - lo) / (hi - lo))
209
+ out.background = colorScaleAt(fmt, t)
210
+ // The scale fills the whole cell, so keep the text legible against it.
211
+ const c = contrastText(out.background)
212
+ if (c) out.color = c
213
+ } else if (fmt.type === 'dataBar') {
214
+ const lo = fmt.minValue ?? Math.min(0, stat?.min ?? 0)
215
+ const hi = fmt.maxValue ?? stat?.max ?? n
216
+ const span = hi - lo || 1
217
+ const percent = clamp01((n - lo) / span) * 100
218
+ const negative = n < 0
219
+ out.dataBar = {
220
+ percent,
221
+ color: negative ? (fmt.negativeColor ?? '#ef4444') : fmt.color,
222
+ fromRight: false,
223
+ }
224
+ if (fmt.showValue === false) out.color = 'transparent'
225
+ } else if (fmt.type === 'iconSet') {
226
+ const set = ICON_SETS[fmt.set ?? 'arrows']
227
+ out.icon = pickIcon(set, fmt.thresholds, n)
228
+ if (fmt.iconOnly) out.iconOnly = true
229
+ }
230
+ }
231
+ return out
232
+ }
233
+
234
+ /** Column ids that need a numeric min/max precomputed (colorScale/dataBar). */
235
+ export function formatsNeedingStats(
236
+ formats: ReadonlyArray<ConditionalFormat<any>>,
237
+ ): boolean {
238
+ return formats.some((f) => f.type === 'colorScale' || f.type === 'dataBar')
239
+ }