@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.
- package/LICENSE +33 -0
- package/README.md +39 -0
- package/dist/FlexRender.svelte +96 -0
- package/dist/FlexRender.svelte.d.ts +49 -0
- package/dist/SvGrid.svelte +8742 -0
- package/dist/SvGrid.svelte.d.ts +381 -0
- package/dist/SvGridChart.svelte +653 -0
- package/dist/SvGridChart.svelte.d.ts +25 -0
- package/dist/SvGridChart.test.d.ts +1 -0
- package/dist/SvGridChart.test.js +62 -0
- package/dist/SvGridDropdown.svelte +675 -0
- package/dist/SvGridDropdown.svelte.d.ts +40 -0
- package/dist/a11y.contract.test.d.ts +1 -0
- package/dist/a11y.contract.test.js +39 -0
- package/dist/a11y.d.ts +38 -0
- package/dist/a11y.js +37 -0
- package/dist/a11y.test.d.ts +1 -0
- package/dist/a11y.test.js +44 -0
- package/dist/cell-formatting.d.ts +17 -0
- package/dist/cell-formatting.js +141 -0
- package/dist/cell-formatting.test.d.ts +1 -0
- package/dist/cell-formatting.test.js +234 -0
- package/dist/chart-export.d.ts +14 -0
- package/dist/chart-export.js +112 -0
- package/dist/chart.d.ts +226 -0
- package/dist/chart.js +732 -0
- package/dist/chart.test.d.ts +1 -0
- package/dist/chart.test.js +289 -0
- package/dist/collaboration.d.ts +74 -0
- package/dist/collaboration.js +98 -0
- package/dist/collaboration.test.d.ts +1 -0
- package/dist/collaboration.test.js +65 -0
- package/dist/conditional-formatting.d.ts +91 -0
- package/dist/conditional-formatting.js +170 -0
- package/dist/conditional-formatting.test.d.ts +1 -0
- package/dist/conditional-formatting.test.js +87 -0
- package/dist/core.coverage.test.d.ts +1 -0
- package/dist/core.coverage.test.js +186 -0
- package/dist/core.d.ts +370 -0
- package/dist/core.js +568 -0
- package/dist/core.performance.test.d.ts +1 -0
- package/dist/core.performance.test.js +29 -0
- package/dist/createGrid.svelte.d.ts +6 -0
- package/dist/createGrid.svelte.js +17 -0
- package/dist/createGrid.test.d.ts +1 -0
- package/dist/createGrid.test.js +9 -0
- package/dist/createGridState.svelte.d.ts +3 -0
- package/dist/createGridState.svelte.js +13 -0
- package/dist/editors/cell-editors.d.ts +23 -0
- package/dist/editors/cell-editors.js +97 -0
- package/dist/editors/cell-editors.test.d.ts +1 -0
- package/dist/editors/cell-editors.test.js +75 -0
- package/dist/fill-patterns.d.ts +30 -0
- package/dist/fill-patterns.js +207 -0
- package/dist/fill-patterns.test.d.ts +1 -0
- package/dist/fill-patterns.test.js +81 -0
- package/dist/filtering/excel-filters.d.ts +26 -0
- package/dist/filtering/excel-filters.js +64 -0
- package/dist/filtering/excel-filters.test.d.ts +1 -0
- package/dist/filtering/excel-filters.test.js +116 -0
- package/dist/filtering/locale-filter.test.d.ts +13 -0
- package/dist/filtering/locale-filter.test.js +189 -0
- package/dist/flex-render.d.ts +1 -0
- package/dist/flex-render.js +2 -0
- package/dist/flex-render.test.d.ts +1 -0
- package/dist/flex-render.test.js +142 -0
- package/dist/group-aggregate.test.d.ts +1 -0
- package/dist/group-aggregate.test.js +32 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +27 -0
- package/dist/keyboard.d.ts +8 -0
- package/dist/keyboard.js +91 -0
- package/dist/keyboard.test.d.ts +1 -0
- package/dist/keyboard.test.js +27 -0
- package/dist/merge-objects.d.ts +9 -0
- package/dist/merge-objects.js +28 -0
- package/dist/named-views.d.ts +42 -0
- package/dist/named-views.js +80 -0
- package/dist/named-views.test.d.ts +1 -0
- package/dist/named-views.test.js +57 -0
- package/dist/new-features.test.d.ts +1 -0
- package/dist/new-features.test.js +183 -0
- package/dist/render-component.d.ts +13 -0
- package/dist/render-component.js +14 -0
- package/dist/render-component.test.d.ts +1 -0
- package/dist/render-component.test.js +41 -0
- package/dist/server-data-source.d.ts +76 -0
- package/dist/server-data-source.js +83 -0
- package/dist/server-data-source.test.d.ts +1 -0
- package/dist/server-data-source.test.js +81 -0
- package/dist/sparkline.d.ts +56 -0
- package/dist/sparkline.js +97 -0
- package/dist/sparkline.test.d.ts +1 -0
- package/dist/sparkline.test.js +52 -0
- package/dist/static-functions.d.ts +1 -0
- package/dist/static-functions.js +1 -0
- package/dist/subscribe.d.ts +9 -0
- package/dist/subscribe.js +31 -0
- package/dist/subscribe.test.d.ts +1 -0
- package/dist/subscribe.test.js +93 -0
- package/dist/sv-grid-scrollbar.d.ts +1 -0
- package/dist/sv-grid-scrollbar.js +310 -0
- package/dist/svgrid-wrapper.types.d.ts +341 -0
- package/dist/svgrid-wrapper.types.js +1 -0
- package/dist/svgrid.api-extensions.test.d.ts +14 -0
- package/dist/svgrid.api-extensions.test.js +334 -0
- package/dist/svgrid.api.test.d.ts +15 -0
- package/dist/svgrid.api.test.js +169 -0
- package/dist/svgrid.behavior.test.d.ts +15 -0
- package/dist/svgrid.behavior.test.js +605 -0
- package/dist/svgrid.column-reorder.test.d.ts +15 -0
- package/dist/svgrid.column-reorder.test.js +224 -0
- package/dist/svgrid.features.test.d.ts +1 -0
- package/dist/svgrid.features.test.js +129 -0
- package/dist/svgrid.interaction.test.d.ts +9 -0
- package/dist/svgrid.interaction.test.js +318 -0
- package/dist/svgrid.locale-filtering.test.d.ts +15 -0
- package/dist/svgrid.locale-filtering.test.js +261 -0
- package/dist/svgrid.new-features.wrapper.test.d.ts +1 -0
- package/dist/svgrid.new-features.wrapper.test.js +136 -0
- package/dist/svgrid.row-pinning.test.d.ts +14 -0
- package/dist/svgrid.row-pinning.test.js +250 -0
- package/dist/svgrid.set-filter.test.d.ts +17 -0
- package/dist/svgrid.set-filter.test.js +268 -0
- package/dist/svgrid.wrapper.test.d.ts +1 -0
- package/dist/svgrid.wrapper.test.js +33 -0
- package/dist/test-setup.d.ts +1 -0
- package/dist/test-setup.js +29 -0
- package/dist/transaction.test.d.ts +1 -0
- package/dist/transaction.test.js +89 -0
- package/dist/virtualization/column-virtualizer.d.ts +20 -0
- package/dist/virtualization/column-virtualizer.js +19 -0
- package/dist/virtualization/column-virtualizer.test.d.ts +1 -0
- package/dist/virtualization/column-virtualizer.test.js +25 -0
- package/dist/virtualization/svelte-virtualizer.svelte.d.ts +11 -0
- package/dist/virtualization/svelte-virtualizer.svelte.js +20 -0
- package/dist/virtualization/types.d.ts +28 -0
- package/dist/virtualization/types.js +1 -0
- package/dist/virtualization/virtualizer.d.ts +13 -0
- package/dist/virtualization/virtualizer.js +232 -0
- package/dist/virtualization/virtualizer.test.d.ts +1 -0
- package/dist/virtualization/virtualizer.test.js +41 -0
- package/package.json +69 -0
- package/src/FlexRender.svelte +96 -0
- package/src/SvGrid.svelte +8742 -0
- package/src/SvGridChart.svelte +653 -0
- package/src/SvGridChart.test.ts +69 -0
- package/src/SvGridDropdown.svelte +675 -0
- package/src/a11y.contract.test.ts +49 -0
- package/src/a11y.test.ts +59 -0
- package/src/a11y.ts +59 -0
- package/src/cell-formatting.test.ts +273 -0
- package/src/cell-formatting.ts +169 -0
- package/src/chart-export.ts +144 -0
- package/src/chart.test.ts +311 -0
- package/src/chart.ts +934 -0
- package/src/collaboration.test.ts +74 -0
- package/src/collaboration.ts +161 -0
- package/src/conditional-formatting.test.ts +107 -0
- package/src/conditional-formatting.ts +239 -0
- package/src/core.coverage.test.ts +230 -0
- package/src/core.performance.test.ts +30 -0
- package/src/core.ts +991 -0
- package/src/createGrid.svelte.ts +42 -0
- package/src/createGrid.test.ts +10 -0
- package/src/createGridState.svelte.ts +17 -0
- package/src/editors/cell-editors.test.ts +80 -0
- package/src/editors/cell-editors.ts +127 -0
- package/src/fill-patterns.test.ts +93 -0
- package/src/fill-patterns.ts +229 -0
- package/src/filtering/excel-filters.test.ts +131 -0
- package/src/filtering/excel-filters.ts +101 -0
- package/src/filtering/locale-filter.test.ts +220 -0
- package/src/flex-render.test.ts +143 -0
- package/src/flex-render.ts +3 -0
- package/src/group-aggregate.test.ts +48 -0
- package/src/index.ts +159 -0
- package/src/keyboard.test.ts +59 -0
- package/src/keyboard.ts +97 -0
- package/src/merge-objects.ts +48 -0
- package/src/named-views.test.ts +66 -0
- package/src/named-views.ts +120 -0
- package/src/new-features.test.ts +217 -0
- package/src/render-component.test.ts +51 -0
- package/src/render-component.ts +28 -0
- package/src/server-data-source.test.ts +88 -0
- package/src/server-data-source.ts +163 -0
- package/src/sparkline.test.ts +59 -0
- package/src/sparkline.ts +164 -0
- package/src/static-functions.ts +11 -0
- package/src/subscribe.test.ts +103 -0
- package/src/subscribe.ts +38 -0
- package/src/sv-grid-scrollbar.ts +347 -0
- package/src/svgrid-wrapper.types.ts +382 -0
- package/src/svgrid.api-extensions.test.ts +362 -0
- package/src/svgrid.api.test.ts +192 -0
- package/src/svgrid.behavior.test.ts +657 -0
- package/src/svgrid.column-reorder.test.ts +234 -0
- package/src/svgrid.features.test.ts +157 -0
- package/src/svgrid.interaction.test.ts +355 -0
- package/src/svgrid.locale-filtering.test.ts +259 -0
- package/src/svgrid.new-features.wrapper.test.ts +164 -0
- package/src/svgrid.row-pinning.test.ts +266 -0
- package/src/svgrid.set-filter.test.ts +270 -0
- package/src/svgrid.wrapper.test.ts +35 -0
- package/src/test-setup.ts +37 -0
- package/src/transaction.test.ts +100 -0
- package/src/virtualization/column-virtualizer.test.ts +27 -0
- package/src/virtualization/column-virtualizer.ts +30 -0
- package/src/virtualization/svelte-virtualizer.svelte.ts +24 -0
- package/src/virtualization/types.ts +30 -0
- package/src/virtualization/virtualizer.test.ts +47 -0
- package/src/virtualization/virtualizer.ts +270 -0
package/src/sparkline.ts
ADDED
|
@@ -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,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
|
+
})
|
package/src/subscribe.ts
ADDED
|
@@ -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 {}
|