@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,164 @@
1
+ /**
2
+ * Sparkline geometry. Pure functions that turn an array of numbers into the
3
+ * SVG primitives a tiny in-cell chart needs - no DOM, so it is trivially
4
+ * unit-testable and the `<SvGrid>` render component just paints the result.
5
+ *
6
+ * Four chart types, mirroring the de-facto enterprise-grid set:
7
+ * - `line` a single polyline
8
+ * - `area` a polyline plus a filled area down to the baseline
9
+ * - `bar` one column per value, scaled from the dataset min..max
10
+ * - `winloss` fixed-height up/down bars (sign only), e.g. W/L streaks
11
+ */
12
+ export type SparklineType = 'line' | 'area' | 'bar' | 'winloss'
13
+
14
+ export type SparklineConfig = {
15
+ type?: SparklineType
16
+ /** Stroke (line/area) or positive fill (bar/winloss). */
17
+ color?: string
18
+ /** Fill for negative bars / losses. Defaults to a red. */
19
+ negativeColor?: string
20
+ width?: number
21
+ height?: number
22
+ /** Fix the value scale instead of deriving it from the row's own data. */
23
+ min?: number
24
+ max?: number
25
+ lineWidth?: number
26
+ /** Draw a dot on the last point (line/area). Default true. */
27
+ lastPoint?: boolean
28
+ }
29
+
30
+ export type SparklineBar = {
31
+ x: number
32
+ y: number
33
+ w: number
34
+ h: number
35
+ negative: boolean
36
+ }
37
+
38
+ export type SparklineGeometry = {
39
+ width: number
40
+ height: number
41
+ type: SparklineType
42
+ color: string
43
+ negativeColor: string
44
+ lineWidth: number
45
+ /** `line` / `area`: the polyline through every point. */
46
+ linePath: string
47
+ /** `area`: closed path filled down to the baseline. */
48
+ areaPath: string
49
+ /** `bar` / `winloss`: one rect per value. */
50
+ bars: SparklineBar[]
51
+ /** `line` / `area`: the final point, for the end-cap dot. */
52
+ lastPoint: { x: number; y: number } | null
53
+ }
54
+
55
+ const DEFAULTS = {
56
+ width: 88,
57
+ height: 22,
58
+ color: 'var(--sg-accent, #2563eb)',
59
+ negativeColor: '#ef4444',
60
+ lineWidth: 1.5,
61
+ }
62
+
63
+ /** Coerce loose cell values (arrays, comma strings) into a number array. */
64
+ export function toSparklineValues(value: unknown): number[] {
65
+ if (Array.isArray(value)) {
66
+ return value.map((v) => Number(v)).filter((n) => Number.isFinite(n))
67
+ }
68
+ if (typeof value === 'string' && value.trim()) {
69
+ return value
70
+ .split(/[,\s]+/)
71
+ .map((v) => Number(v))
72
+ .filter((n) => Number.isFinite(n))
73
+ }
74
+ return []
75
+ }
76
+
77
+ export function buildSparkline(
78
+ values: number[],
79
+ cfg: SparklineConfig = {},
80
+ ): SparklineGeometry | null {
81
+ if (!values.length) return null
82
+
83
+ const width = cfg.width ?? DEFAULTS.width
84
+ const height = cfg.height ?? DEFAULTS.height
85
+ const type = cfg.type ?? 'line'
86
+ const color = cfg.color ?? DEFAULTS.color
87
+ const negativeColor = cfg.negativeColor ?? DEFAULTS.negativeColor
88
+ const lineWidth = cfg.lineWidth ?? DEFAULTS.lineWidth
89
+ const pad = Math.ceil(lineWidth) + 1
90
+ const innerW = Math.max(1, width - pad * 2)
91
+ const innerH = Math.max(1, height - pad * 2)
92
+
93
+ const base: Omit<
94
+ SparklineGeometry,
95
+ 'linePath' | 'areaPath' | 'bars' | 'lastPoint'
96
+ > = { width, height, type, color, negativeColor, lineWidth }
97
+
98
+ if (type === 'winloss') {
99
+ const n = values.length
100
+ const gap = n > 1 ? Math.min(2, innerW / n / 3) : 0
101
+ const slot = innerW / n
102
+ const barW = Math.max(1, slot - gap)
103
+ const mid = height / 2
104
+ const h = innerH / 2
105
+ const bars: SparklineBar[] = values.map((v, i) => {
106
+ const x = pad + i * slot + (slot - barW) / 2
107
+ if (v > 0) return { x, y: mid - h, w: barW, h, negative: false }
108
+ if (v < 0) return { x, y: mid, w: barW, h, negative: true }
109
+ return { x, y: mid - 1, w: barW, h: 2, negative: false }
110
+ })
111
+ return { ...base, linePath: '', areaPath: '', bars, lastPoint: null }
112
+ }
113
+
114
+ const min = cfg.min ?? Math.min(...values)
115
+ const max = cfg.max ?? Math.max(...values)
116
+ const span = max - min || 1
117
+
118
+ if (type === 'bar') {
119
+ const n = values.length
120
+ const gap = n > 1 ? Math.min(2, innerW / n / 3) : 0
121
+ const slot = innerW / n
122
+ const barW = Math.max(1, slot - gap)
123
+ // Bars grow from a zero baseline when the data spans zero, else from min.
124
+ const baseVal = min < 0 && max > 0 ? 0 : min
125
+ const baseY = pad + innerH - ((baseVal - min) / span) * innerH
126
+ const bars: SparklineBar[] = values.map((v, i) => {
127
+ const x = pad + i * slot + (slot - barW) / 2
128
+ const vy = pad + innerH - ((v - min) / span) * innerH
129
+ const top = Math.min(vy, baseY)
130
+ const h = Math.max(1, Math.abs(vy - baseY))
131
+ return { x, y: top, w: barW, h, negative: v < baseVal }
132
+ })
133
+ return { ...base, linePath: '', areaPath: '', bars, lastPoint: null }
134
+ }
135
+
136
+ // line / area
137
+ const n = values.length
138
+ const stepX = n > 1 ? innerW / (n - 1) : 0
139
+ const pts = values.map((v, i) => ({
140
+ x: pad + i * stepX,
141
+ y: pad + innerH - ((v - min) / span) * innerH,
142
+ }))
143
+ const linePath = pts
144
+ .map((p, i) => `${i === 0 ? 'M' : 'L'}${round(p.x)},${round(p.y)}`)
145
+ .join(' ')
146
+ const first = pts[0]!
147
+ const last = pts[pts.length - 1]!
148
+ const baseY = pad + innerH
149
+ const areaPath =
150
+ type === 'area'
151
+ ? `${linePath} L${round(last.x)},${round(baseY)} L${round(first.x)},${round(baseY)} Z`
152
+ : ''
153
+ return {
154
+ ...base,
155
+ linePath,
156
+ areaPath,
157
+ bars: [],
158
+ lastPoint: cfg.lastPoint === false ? null : { x: last.x, y: last.y },
159
+ }
160
+ }
161
+
162
+ function round(n: number): number {
163
+ return Math.round(n * 100) / 100
164
+ }
@@ -0,0 +1,11 @@
1
+ export {
2
+ createCoreRowModel,
3
+ createExpandedRowModel,
4
+ createFilteredRowModel,
5
+ createGroupedRowModel,
6
+ createPaginatedRowModel,
7
+ createSortedRowModel,
8
+ filterFns,
9
+ sortFns,
10
+ tableFeatures,
11
+ } from './core'
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Unit tests for the grid-store subscribe helper.
3
+ *
4
+ * The function attaches a selector to grid.store and shallow-compares each
5
+ * emitted state, updating the returned `current` getter only when the
6
+ * selection actually changed.
7
+ */
8
+ import { describe, expect, it } from 'vitest'
9
+ import { subscribeGrid, subscribeSvGrid } from './subscribe'
10
+
11
+ type Listener = () => void
12
+
13
+ /**
14
+ * Minimal hand-rolled stand-in for `grid.store` that mimics the TanStack
15
+ * Store shape `subscribeGrid` reads: `state` getter + `subscribe(listener)`
16
+ * + `setState(updater)` that fires the listeners.
17
+ */
18
+ function makeFakeStore<T extends object>(initial: T) {
19
+ let state = initial
20
+ const listeners = new Set<Listener>()
21
+ const store = {
22
+ get state() {
23
+ return state
24
+ },
25
+ subscribe(listener: Listener) {
26
+ listeners.add(listener)
27
+ return () => listeners.delete(listener)
28
+ },
29
+ setState(updater: (prev: T) => T) {
30
+ state = updater(state)
31
+ for (const l of listeners) l()
32
+ },
33
+ }
34
+ return store
35
+ }
36
+
37
+ describe('subscribeGrid', () => {
38
+ it('starts with the current selected value', () => {
39
+ const store = makeFakeStore({ sorting: ['name'], rowSelection: { '0': true } })
40
+ const grid = { store } as any
41
+ const sub = subscribeGrid(grid, (s) => s.sorting)
42
+ expect(sub.current).toEqual(['name'])
43
+ })
44
+
45
+ it('updates the selected value when the store fires a change', () => {
46
+ const store = makeFakeStore({ sorting: ['name'] })
47
+ const grid = { store } as any
48
+ const sub = subscribeGrid(grid, (s) => s.sorting)
49
+ store.setState((prev) => ({ ...prev, sorting: ['age'] }))
50
+ expect(sub.current).toEqual(['age'])
51
+ })
52
+
53
+ it('keeps the previous reference when shallow-compare reports equal', () => {
54
+ const store = makeFakeStore<{ sel: Record<string, boolean> }>({ sel: { a: true } })
55
+ const grid = { store } as any
56
+ const sub = subscribeGrid(grid, (s) => s.sel)
57
+ const initial = sub.current
58
+ // A new object with the same key/value content - shallow-equal to the prev.
59
+ store.setState(() => ({ sel: { a: true } }))
60
+ expect(sub.current).toBe(initial)
61
+ })
62
+
63
+ it('replaces the reference when a key value changes', () => {
64
+ const store = makeFakeStore<{ sel: Record<string, boolean> }>({ sel: { a: true } })
65
+ const grid = { store } as any
66
+ const sub = subscribeGrid(grid, (s) => s.sel)
67
+ const initial = sub.current
68
+ store.setState(() => ({ sel: { a: false } }))
69
+ expect(sub.current).not.toBe(initial)
70
+ expect(sub.current).toEqual({ a: false })
71
+ })
72
+
73
+ it('treats a different key set as a change', () => {
74
+ const store = makeFakeStore<{ sel: Record<string, boolean> }>({ sel: { a: true } })
75
+ const grid = { store } as any
76
+ const sub = subscribeGrid(grid, (s) => s.sel)
77
+ store.setState(() => ({ sel: { a: true, b: true } }))
78
+ expect(sub.current).toEqual({ a: true, b: true })
79
+ })
80
+
81
+ it('treats primitive selections (numbers, strings) correctly', () => {
82
+ const store = makeFakeStore({ count: 1 })
83
+ const grid = { store } as any
84
+ const sub = subscribeGrid(grid, (s) => s.count)
85
+ expect(sub.current).toBe(1)
86
+ store.setState(() => ({ count: 1 }))
87
+ expect(sub.current).toBe(1)
88
+ store.setState(() => ({ count: 2 }))
89
+ expect(sub.current).toBe(2)
90
+ })
91
+
92
+ it('treats null and undefined as inequal to objects (early-return shallowCompare branch)', () => {
93
+ const store = makeFakeStore<{ sel: Record<string, boolean> | null }>({ sel: { a: true } })
94
+ const grid = { store } as any
95
+ const sub = subscribeGrid(grid, (s) => s.sel)
96
+ store.setState(() => ({ sel: null }))
97
+ expect(sub.current).toBeNull()
98
+ })
99
+
100
+ it('exports the subscribeSvGrid alias as the same function', () => {
101
+ expect(subscribeSvGrid).toBe(subscribeGrid)
102
+ })
103
+ })
@@ -0,0 +1,38 @@
1
+ import type { RowData, SvGrid, TableFeatures } from './core'
2
+
3
+ const shallowCompare = (a: unknown, b: unknown) => {
4
+ if (Object.is(a, b)) return true
5
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return false
6
+ const aObj = a as Record<string, unknown>
7
+ const bObj = b as Record<string, unknown>
8
+ const aKeys = Object.keys(aObj)
9
+ const bKeys = Object.keys(bObj)
10
+ if (aKeys.length !== bKeys.length) return false
11
+ return aKeys.every((key) => Object.is(aObj[key], bObj[key]))
12
+ }
13
+
14
+ /**
15
+ * Fine-grained subscription to `table.store` using `useSelector` with shallow
16
+ * comparison. Call from `<script>` so the selector is contextually typed.
17
+ */
18
+ export function subscribeGrid<
19
+ TFeatures extends TableFeatures,
20
+ TData extends RowData,
21
+ TSelected,
22
+ >(
23
+ grid: SvGrid<TData>,
24
+ selector: (state: Record<string, any>) => TSelected,
25
+ ) {
26
+ let current = selector(grid.store.state)
27
+ grid.store.subscribe(() => {
28
+ const next = selector(grid.store.state)
29
+ if (!shallowCompare(current, next)) current = next
30
+ })
31
+ return {
32
+ get current() {
33
+ return current
34
+ },
35
+ }
36
+ }
37
+
38
+ export const subscribeSvGrid = subscribeGrid
@@ -0,0 +1,347 @@
1
+ type Orientation = 'horizontal' | 'vertical'
2
+
3
+ const ARROW_SIZE = 16
4
+ const HOLD_DELAY_MS = 280
5
+ const HOLD_INTERVAL_MS = 50
6
+ const SVG_NS = 'http://www.w3.org/2000/svg'
7
+
8
+ /**
9
+ * Build the chevron icon as real DOM nodes so the component is compatible
10
+ * with strict Content Security Policies / Trusted Types (no innerHTML).
11
+ */
12
+ function createChevronSvg(): SVGElement {
13
+ const svg = document.createElementNS(SVG_NS, 'svg')
14
+ svg.setAttribute('viewBox', '0 0 24 24')
15
+ svg.setAttribute('fill', 'none')
16
+ svg.setAttribute('stroke', 'currentColor')
17
+ svg.setAttribute('stroke-width', '3')
18
+ svg.setAttribute('stroke-linecap', 'round')
19
+ svg.setAttribute('stroke-linejoin', 'round')
20
+ const path = document.createElementNS(SVG_NS, 'path')
21
+ path.setAttribute('d', 'M6 9l6 6 6-6')
22
+ svg.appendChild(path)
23
+ return svg
24
+ }
25
+
26
+ /**
27
+ * Each visible part of the scrollbar reads from a CSS custom property so
28
+ * applications can theme it (e.g. a dark theme) without forking the
29
+ * component. The fallbacks are the original light-mode palette so existing
30
+ * embedders keep their look without changes.
31
+ */
32
+ const STYLE_TEMPLATE = `
33
+ :host {
34
+ display: flex;
35
+ user-select: none;
36
+ background: var(--sg-scrollbar-bg, #eef2f8);
37
+ box-shadow: inset 0 0 0 1px var(--sg-scrollbar-border, rgba(15, 23, 42, 0.04));
38
+ /* Start hidden + non-interactive - viewport/content sizes are 0 until
39
+ after the first layout pass, so without this default the scrollbar
40
+ paints visible for one frame and then JS hides it on mount. That
41
+ flicker is what shows up as a "flashing horizontal scrollbar"
42
+ every time a demo first renders. updateGeometry() switches us to
43
+ opacity 1 the moment the grid genuinely has overflow. */
44
+ opacity: 0;
45
+ pointer-events: none;
46
+ transition: opacity 120ms ease;
47
+ }
48
+ :host([orientation="vertical"]) {
49
+ flex-direction: column;
50
+ width: ${ARROW_SIZE}px;
51
+ }
52
+ :host([orientation="horizontal"]) {
53
+ flex-direction: row;
54
+ height: ${ARROW_SIZE}px;
55
+ }
56
+ .arrow {
57
+ flex: none;
58
+ display: flex;
59
+ align-items: center;
60
+ justify-content: center;
61
+ width: ${ARROW_SIZE}px;
62
+ height: ${ARROW_SIZE}px;
63
+ margin: 0;
64
+ padding: 0;
65
+ border: 0;
66
+ border-radius: 3px;
67
+ background: transparent;
68
+ color: var(--sg-scrollbar-arrow, #64748b);
69
+ cursor: pointer;
70
+ transition: background-color 100ms ease, color 100ms ease;
71
+ }
72
+ .arrow:hover {
73
+ background: var(--sg-scrollbar-arrow-hover-bg, #dde4ee);
74
+ color: var(--sg-scrollbar-arrow-hover, #1f2937);
75
+ }
76
+ .arrow:active {
77
+ background: var(--sg-scrollbar-arrow-active-bg, #c7d1df);
78
+ color: var(--sg-scrollbar-arrow-active, #0f172a);
79
+ }
80
+ .arrow:disabled {
81
+ color: var(--sg-scrollbar-arrow-disabled, #cbd5e1);
82
+ cursor: default;
83
+ }
84
+ .arrow:disabled:hover {
85
+ background: transparent;
86
+ }
87
+ .arrow svg {
88
+ width: 10px;
89
+ height: 10px;
90
+ display: block;
91
+ }
92
+ :host([orientation="vertical"]) .arrow-start svg {
93
+ transform: rotate(180deg);
94
+ }
95
+ :host([orientation="horizontal"]) .arrow-start svg {
96
+ transform: rotate(90deg);
97
+ }
98
+ :host([orientation="horizontal"]) .arrow-end svg {
99
+ transform: rotate(-90deg);
100
+ }
101
+ .track {
102
+ position: relative;
103
+ flex: 1 1 0;
104
+ min-width: 0;
105
+ min-height: 0;
106
+ }
107
+ .thumb {
108
+ position: absolute;
109
+ border-radius: var(--sg-scrollbar-thumb-radius, 6px);
110
+ background: var(--sg-scrollbar-thumb, #b1bccd);
111
+ cursor: pointer;
112
+ transition: background-color 100ms ease;
113
+ }
114
+ .thumb:hover {
115
+ background: var(--sg-scrollbar-thumb-hover, #8693a7);
116
+ }
117
+ .thumb:active {
118
+ background: var(--sg-scrollbar-thumb-active, #64748b);
119
+ }
120
+ :host([orientation="vertical"]) .thumb {
121
+ left: 3px;
122
+ right: 3px;
123
+ }
124
+ :host([orientation="horizontal"]) .thumb {
125
+ top: 3px;
126
+ bottom: 3px;
127
+ }
128
+ `
129
+
130
+ class SvGridScrollbarElement extends HTMLElement {
131
+ static get observedAttributes() {
132
+ return ['orientation', 'viewport-size', 'content-size', 'value', 'step']
133
+ }
134
+
135
+ private track!: HTMLDivElement
136
+ private thumb!: HTMLDivElement
137
+ private startArrow!: HTMLButtonElement
138
+ private endArrow!: HTMLButtonElement
139
+ private dragging = false
140
+ private dragStart = 0
141
+ private valueStart = 0
142
+ private holdTimer: ReturnType<typeof setTimeout> | null = null
143
+ private resizeObserver: ResizeObserver | null = null
144
+
145
+ connectedCallback() {
146
+ if (this.shadowRoot) return
147
+ const shadow = this.attachShadow({ mode: 'open' })
148
+ const style = document.createElement('style')
149
+ style.textContent = STYLE_TEMPLATE
150
+
151
+ this.startArrow = this.createArrow('start')
152
+ this.endArrow = this.createArrow('end')
153
+ this.track = document.createElement('div')
154
+ this.track.className = 'track'
155
+ this.thumb = document.createElement('div')
156
+ this.thumb.className = 'thumb'
157
+ this.track.appendChild(this.thumb)
158
+
159
+ shadow.append(style, this.startArrow, this.track, this.endArrow)
160
+
161
+ this.thumb.addEventListener('pointerdown', this.onThumbPointerDown)
162
+ this.track.addEventListener('pointerdown', this.onTrackPointerDown)
163
+ this.startArrow.addEventListener('pointerdown', this.onArrowStartPointerDown)
164
+ this.endArrow.addEventListener('pointerdown', this.onArrowEndPointerDown)
165
+ for (const btn of [this.startArrow, this.endArrow]) {
166
+ btn.addEventListener('pointerup', this.stopHold)
167
+ btn.addEventListener('pointerleave', this.stopHold)
168
+ btn.addEventListener('pointercancel', this.stopHold)
169
+ }
170
+
171
+ this.resizeObserver = new ResizeObserver(() => this.render())
172
+ this.resizeObserver.observe(this)
173
+
174
+ this.render()
175
+ }
176
+
177
+ disconnectedCallback() {
178
+ this.stopHold()
179
+ this.resizeObserver?.disconnect()
180
+ this.resizeObserver = null
181
+ document.removeEventListener('pointermove', this.onPointerMove)
182
+ document.removeEventListener('pointerup', this.onPointerUp)
183
+ }
184
+
185
+ attributeChangedCallback() {
186
+ this.render()
187
+ }
188
+
189
+ private get orientation(): Orientation {
190
+ return this.getAttribute('orientation') === 'horizontal' ? 'horizontal' : 'vertical'
191
+ }
192
+
193
+ private get viewportSize() {
194
+ return Number(this.getAttribute('viewport-size') || '0')
195
+ }
196
+
197
+ private get contentSize() {
198
+ return Number(this.getAttribute('content-size') || '0')
199
+ }
200
+
201
+ private get value() {
202
+ return Number(this.getAttribute('value') || '0')
203
+ }
204
+
205
+ private get step() {
206
+ const raw = Number(this.getAttribute('step') || '40')
207
+ return raw > 0 ? raw : 40
208
+ }
209
+
210
+ private get maxValue() {
211
+ return Math.max(this.contentSize - this.viewportSize, 0)
212
+ }
213
+
214
+ /** Measured length of the track between the two arrows, in the active axis. */
215
+ private getTrackLength(): number {
216
+ const rect = this.track.getBoundingClientRect()
217
+ const measured = this.orientation === 'vertical' ? rect.height : rect.width
218
+ if (measured > 0) return measured
219
+ // Pre-layout fallback so the thumb still has a sensible size on first render.
220
+ return Math.max(this.viewportSize - ARROW_SIZE * 2, 1)
221
+ }
222
+
223
+ private computeThumbSize(trackLength: number): number {
224
+ if (this.contentSize <= 0 || this.viewportSize <= 0 || trackLength <= 0) return 0
225
+ const ratio = Math.min(this.viewportSize / this.contentSize, 1)
226
+ return Math.max(20, ratio * trackLength)
227
+ }
228
+
229
+ private createArrow(side: 'start' | 'end'): HTMLButtonElement {
230
+ const btn = document.createElement('button')
231
+ btn.type = 'button'
232
+ btn.className = `arrow arrow-${side}`
233
+ btn.setAttribute(
234
+ 'aria-label',
235
+ side === 'start' ? 'Scroll back' : 'Scroll forward',
236
+ )
237
+ btn.appendChild(createChevronSvg())
238
+ return btn
239
+ }
240
+
241
+ private onArrowStartPointerDown = (event: PointerEvent) => {
242
+ event.preventDefault()
243
+ this.startHold(-1)
244
+ }
245
+
246
+ private onArrowEndPointerDown = (event: PointerEvent) => {
247
+ event.preventDefault()
248
+ this.startHold(1)
249
+ }
250
+
251
+ private startHold(direction: -1 | 1) {
252
+ this.stopHold()
253
+ const tick = () => this.emitScroll(this.value + direction * this.step)
254
+ tick()
255
+ this.holdTimer = setTimeout(() => {
256
+ this.holdTimer = setInterval(tick, HOLD_INTERVAL_MS)
257
+ }, HOLD_DELAY_MS)
258
+ }
259
+
260
+ private stopHold = () => {
261
+ if (this.holdTimer !== null) {
262
+ clearTimeout(this.holdTimer)
263
+ clearInterval(this.holdTimer)
264
+ this.holdTimer = null
265
+ }
266
+ }
267
+
268
+ private onThumbPointerDown = (event: PointerEvent) => {
269
+ event.preventDefault()
270
+ this.dragging = true
271
+ this.dragStart = this.orientation === 'vertical' ? event.clientY : event.clientX
272
+ this.valueStart = this.value
273
+ document.addEventListener('pointermove', this.onPointerMove)
274
+ document.addEventListener('pointerup', this.onPointerUp)
275
+ }
276
+
277
+ private onTrackPointerDown = (event: PointerEvent) => {
278
+ if (event.target === this.thumb) return
279
+ const rect = this.track.getBoundingClientRect()
280
+ const coord =
281
+ this.orientation === 'vertical' ? event.clientY - rect.top : event.clientX - rect.left
282
+ const trackSize = this.orientation === 'vertical' ? rect.height : rect.width
283
+ const thumbSize = this.computeThumbSize(trackSize)
284
+ const available = Math.max(trackSize - thumbSize, 1)
285
+ const offset = Math.min(Math.max(coord - thumbSize / 2, 0), available)
286
+ this.emitScroll((offset / available) * this.maxValue)
287
+ }
288
+
289
+ private onPointerMove = (event: PointerEvent) => {
290
+ if (!this.dragging) return
291
+ const current = this.orientation === 'vertical' ? event.clientY : event.clientX
292
+ const delta = current - this.dragStart
293
+ const rect = this.track.getBoundingClientRect()
294
+ const trackSize = this.orientation === 'vertical' ? rect.height : rect.width
295
+ const thumbSize = this.computeThumbSize(trackSize)
296
+ const available = Math.max(trackSize - thumbSize, 1)
297
+ this.emitScroll(this.valueStart + (delta / available) * this.maxValue)
298
+ }
299
+
300
+ private onPointerUp = () => {
301
+ this.dragging = false
302
+ document.removeEventListener('pointermove', this.onPointerMove)
303
+ document.removeEventListener('pointerup', this.onPointerUp)
304
+ }
305
+
306
+ private emitScroll(next: number) {
307
+ const bounded = Math.min(Math.max(next, 0), this.maxValue)
308
+ this.dispatchEvent(
309
+ new CustomEvent('scroll-change', {
310
+ detail: { value: bounded },
311
+ bubbles: true,
312
+ composed: true,
313
+ }),
314
+ )
315
+ }
316
+
317
+ private render() {
318
+ if (!this.shadowRoot) return
319
+ const viewport = this.viewportSize
320
+ const content = this.contentSize
321
+ const hidden = content <= viewport || viewport <= 0
322
+ this.style.opacity = hidden ? '0' : '1'
323
+ this.style.pointerEvents = hidden ? 'none' : 'auto'
324
+
325
+ const trackLength = this.getTrackLength()
326
+ const thumbSize = this.computeThumbSize(trackLength)
327
+ const maxValue = this.maxValue
328
+ const ratio = maxValue > 0 ? this.value / maxValue : 0
329
+ const offset = ratio * Math.max(trackLength - thumbSize, 0)
330
+
331
+ if (this.orientation === 'vertical') {
332
+ this.thumb.style.height = `${thumbSize}px`
333
+ this.thumb.style.transform = `translateY(${offset}px)`
334
+ } else {
335
+ this.thumb.style.width = `${thumbSize}px`
336
+ this.thumb.style.transform = `translateX(${offset}px)`
337
+ }
338
+ this.startArrow.disabled = hidden || this.value <= 0
339
+ this.endArrow.disabled = hidden || this.value >= maxValue
340
+ }
341
+ }
342
+
343
+ if (typeof window !== 'undefined' && !customElements.get('sv-grid-scrollbar')) {
344
+ customElements.define('sv-grid-scrollbar', SvGridScrollbarElement)
345
+ }
346
+
347
+ export {}