bo-grid 0.1.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 +21 -0
- package/README.md +461 -0
- package/dist/format/format.d.ts +5 -0
- package/dist/format/format.js +25 -0
- package/dist/grid/AggregationBar.svelte +45 -0
- package/dist/grid/AggregationBar.svelte.d.ts +8 -0
- package/dist/grid/Cell.svelte +230 -0
- package/dist/grid/Cell.svelte.d.ts +32 -0
- package/dist/grid/Grid.svelte +1339 -0
- package/dist/grid/Grid.svelte.d.ts +76 -0
- package/dist/grid/GroupRow.svelte +110 -0
- package/dist/grid/GroupRow.svelte.d.ts +11 -0
- package/dist/grid/aggregate.d.ts +11 -0
- package/dist/grid/aggregate.js +23 -0
- package/dist/grid/clipboard.d.ts +9 -0
- package/dist/grid/clipboard.js +24 -0
- package/dist/grid/column.d.ts +95 -0
- package/dist/grid/column.js +62 -0
- package/dist/grid/export-xlsx.d.ts +8 -0
- package/dist/grid/export-xlsx.js +19 -0
- package/dist/grid/export.d.ts +19 -0
- package/dist/grid/export.js +48 -0
- package/dist/grid/grouping.d.ts +36 -0
- package/dist/grid/grouping.js +62 -0
- package/dist/grid/heatmap.d.ts +1 -0
- package/dist/grid/heatmap.js +12 -0
- package/dist/grid/pin.d.ts +23 -0
- package/dist/grid/pin.js +24 -0
- package/dist/grid/pivot.d.ts +27 -0
- package/dist/grid/pivot.js +0 -0
- package/dist/grid/reorder.d.ts +2 -0
- package/dist/grid/reorder.js +10 -0
- package/dist/grid/rowheight.d.ts +17 -0
- package/dist/grid/rowheight.js +41 -0
- package/dist/grid/selection.svelte.d.ts +30 -0
- package/dist/grid/selection.svelte.js +64 -0
- package/dist/grid/sizing.d.ts +17 -0
- package/dist/grid/sizing.js +28 -0
- package/dist/grid/source.d.ts +43 -0
- package/dist/grid/source.js +29 -0
- package/dist/grid/source.svelte.d.ts +21 -0
- package/dist/grid/source.svelte.js +53 -0
- package/dist/grid/theme.d.ts +27 -0
- package/dist/grid/theme.js +60 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +24 -0
- package/dist/sparkline/Sparkline.svelte +74 -0
- package/dist/sparkline/Sparkline.svelte.d.ts +9 -0
- package/dist/sparkline/sparkline-render.d.ts +16 -0
- package/dist/sparkline/sparkline-render.js +83 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.js +1 -0
- package/package.json +82 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bo-grid contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# bo-grid
|
|
2
|
+
|
|
3
|
+
Tiny, fast **Svelte 5** data grid for fintech UIs — canvas sparklines, batched
|
|
4
|
+
realtime cell updates, and virtual scrolling in a package that gzips to a few KB.
|
|
5
|
+
A free alternative to the heavyweight grids that paywall these features.
|
|
6
|
+
|
|
7
|
+
**[Live demo](https://bonguynvan.github.io/bo-grid/)** ·
|
|
8
|
+
**[API reference](https://bonguynvan.github.io/bo-grid/api.html)**
|
|
9
|
+
|
|
10
|
+
The demo is a small gallery of seven grid types — a realtime **Trading desk**, a
|
|
11
|
+
grouped **Portfolio** with subtotals and pivot, a general-purpose editable
|
|
12
|
+
**Spreadsheet**, a live **Order book** depth ladder, a **Correlation** heatmap
|
|
13
|
+
matrix, a **Leaderboard** with rank medals and score bars, and a **1M-row** trade
|
|
14
|
+
tape windowed from a synthetic source — switch between them with the tabs.
|
|
15
|
+
|
|
16
|
+
> **Status: v0.1 (early).** Working: config-driven columns, virtual scroll,
|
|
17
|
+
> client sort + filter, multi-cell selection + live range aggregation, row
|
|
18
|
+
> grouping (nested, collapsible, sticky headers, live subtotals), a server-side
|
|
19
|
+
> `RowSource` for huge datasets, CSV/Excel export, drag-to-reorder and
|
|
20
|
+
> drag-to-resize columns, pinned columns, inline cell editing with clipboard
|
|
21
|
+
> copy/paste, sparklines, realtime flash, heatmaps. Unit
|
|
22
|
+
> tests (Vitest), type-check, a headless mount smoke-test, and library + demo
|
|
23
|
+
> bundle-size budgets all run in CI. Feature-complete for v0; a formal WCAG audit
|
|
24
|
+
> is the main thing left — see the roadmap.
|
|
25
|
+
|
|
26
|
+
## Why
|
|
27
|
+
|
|
28
|
+
| | Heavy enterprise grids | bo-grid |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| Price | $$$ / dev / year | Free (MIT) |
|
|
31
|
+
| Sparklines | paid tier | built in |
|
|
32
|
+
| Realtime cell updates | DIY / complex | built-in primitive |
|
|
33
|
+
| Bundle | ~500 KB | a few KB gzip |
|
|
34
|
+
| Svelte | wrapper | native Svelte 5 |
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npm i bo-grid
|
|
40
|
+
# peer dependency: svelte@^5
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Usage
|
|
44
|
+
|
|
45
|
+
```svelte
|
|
46
|
+
<script lang="ts">
|
|
47
|
+
import { Grid, type ColumnDef, type GridRow } from 'bo-grid';
|
|
48
|
+
|
|
49
|
+
const columns: ColumnDef[] = [
|
|
50
|
+
{ type: 'text', key: 'symbol', sub: 'sector', header: 'Symbol', width: 132 },
|
|
51
|
+
{ type: 'price', key: 'price', header: 'Price', width: 88, flash: true },
|
|
52
|
+
{ type: 'percent', key: 'changePct', header: 'Chg %', width: 84 },
|
|
53
|
+
{ type: 'heatmap', key: 'changePct', header: 'Heat', min: -5, max: 5, width: 76 },
|
|
54
|
+
{ type: 'volume', key: 'volume', header: 'Volume', width: 90 },
|
|
55
|
+
{ type: 'date', key: 'listedAt', header: 'Listed', dateStyle: 'short', width: 92 },
|
|
56
|
+
{ type: 'sparkline', key: 'candles', sparkKey: 'candles', header: 'Trend', flex: 1 },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// Rows must expose `id`, `flashSeq`, `flashDir` plus your data fields.
|
|
60
|
+
// Make the hot fields `$state` so updates flash without re-rendering the table.
|
|
61
|
+
let rows: GridRow[] = $state(/* ... */);
|
|
62
|
+
let filter = $state(''); // bind to your own search input
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<Grid {rows} {columns} {filter} height={640} />
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Realtime updates
|
|
69
|
+
|
|
70
|
+
A cell with `flash: true` plays a brief amber flash whenever the row's
|
|
71
|
+
`flashSeq` increments. Drive it from your data source (e.g. a WebSocket):
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
row.flashDir = next >= row.price ? 'up' : 'down';
|
|
75
|
+
row.price = next;
|
|
76
|
+
row.flashSeq++; // triggers the flash on the price cell
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Only on-screen rows render DOM, so off-screen updates cost nothing until they
|
|
80
|
+
scroll into view. Batch bursty feeds into a `requestAnimationFrame` flush to
|
|
81
|
+
keep frames smooth.
|
|
82
|
+
|
|
83
|
+
## Column types
|
|
84
|
+
|
|
85
|
+
`text` · `price` · `percent` · `volume` · `number` · `date` · `heatmap` · `sparkline` · `custom`
|
|
86
|
+
|
|
87
|
+
Sizing: `width` (px) or `flex` (grow weight). See `ColumnDef` for per-type options.
|
|
88
|
+
|
|
89
|
+
### Custom cells
|
|
90
|
+
|
|
91
|
+
Use `type: 'custom'` and pass a `cell` snippet to render anything — badges,
|
|
92
|
+
buttons, links. The snippet receives `{ row, column, value }`:
|
|
93
|
+
|
|
94
|
+
```svelte
|
|
95
|
+
{#snippet cell({ row })}
|
|
96
|
+
<span class:up={row.changePct > 0}>{row.changePct > 0 ? '▲' : '▼'}</span>
|
|
97
|
+
{/snippet}
|
|
98
|
+
|
|
99
|
+
<Grid {rows} {columns} {cell} height={640} />
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Row height
|
|
103
|
+
|
|
104
|
+
Uniform 36px by default. Pass `rowHeight` as a number for a different density, or
|
|
105
|
+
a function for variable per-row heights (in-memory mode):
|
|
106
|
+
|
|
107
|
+
```svelte
|
|
108
|
+
<Grid {rows} {columns} rowHeight={48} height={640} />
|
|
109
|
+
<Grid {rows} {columns} rowHeight={(row, i) => (row.expanded ? 96 : 36)} height={640} />
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Variable heights use a prefix-sum + binary-search virtualizer, so scrolling stays
|
|
113
|
+
O(log n). Source mode is uniform-only (unloaded row heights aren't known).
|
|
114
|
+
|
|
115
|
+
## Sort & filter
|
|
116
|
+
|
|
117
|
+
Click a column header to sort (asc → desc → off). **Shift-click** additional
|
|
118
|
+
headers to sort by multiple columns — each sorted header shows its position in
|
|
119
|
+
the order. Sparkline columns aren't sortable; set `sortable: false` on any column
|
|
120
|
+
to opt out. Sorting is a snapshot — rows hold position while their values update
|
|
121
|
+
in place (trading-grid behaviour), so a realtime feed never reshuffles the view.
|
|
122
|
+
|
|
123
|
+
Pass a `filter` string to quick-filter rows (matches across column values). Drive
|
|
124
|
+
it from your own search input — the grid stays presentation-only. Set `filterRow`
|
|
125
|
+
to add a row of **per-column filter inputs** under the header (rows must match
|
|
126
|
+
every non-empty column filter; in-memory mode).
|
|
127
|
+
|
|
128
|
+
Sorting is uncontrolled by default. To own it (persist it, set an initial sort,
|
|
129
|
+
or sync to the URL), pass a controlled `sort` array and handle `onSortChange`:
|
|
130
|
+
|
|
131
|
+
```svelte
|
|
132
|
+
<Grid {rows} {columns} height={640} {sort} onSortChange={(s) => (sort = s)} />
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## Selection & aggregation
|
|
136
|
+
|
|
137
|
+
Click a cell, then drag or **Shift-click** to extend a rectangular selection.
|
|
138
|
+
Keyboard: **arrows** move, **Shift+arrows** extend, **Home/End** jump to the
|
|
139
|
+
first/last column (**+Ctrl/⌘** for the first/last cell), **PageUp/PageDown** move
|
|
140
|
+
by a page, **Ctrl/⌘+A** select all, **Ctrl/⌘+C** copy the selection as TSV
|
|
141
|
+
(Excel-pasteable), **Ctrl/⌘+V** paste, **Esc** clear.
|
|
142
|
+
|
|
143
|
+
Paste writes a TSV block (from a spreadsheet or the grid's own copy) into
|
|
144
|
+
editable cells starting at the selection's top-left. A single copied value fills
|
|
145
|
+
the whole selection (Excel behaviour); a block clamps to the grid edges. Pasted
|
|
146
|
+
values flow through the same validation as inline editing — non-editable columns
|
|
147
|
+
and invalid numbers are skipped — and each accepted cell emits `onCellEdit`, so
|
|
148
|
+
paste only does anything when you've wired that callback.
|
|
149
|
+
|
|
150
|
+
When more than one cell is selected, a footer bar shows live **Sum / Avg / Count /
|
|
151
|
+
Min / Max** over the numeric cells in the range — and it keeps updating as a
|
|
152
|
+
realtime feed ticks. Choose which stats to show:
|
|
153
|
+
|
|
154
|
+
```svelte
|
|
155
|
+
<Grid {rows} {columns} aggregations={['sum', 'avg', 'count']} height={640} />
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Set `footer` for a **pinned totals row**: every column with a `groupAgg` shows
|
|
159
|
+
that aggregate over all (filtered) rows, sticky to the bottom as you scroll
|
|
160
|
+
(in-memory mode).
|
|
161
|
+
|
|
162
|
+
```svelte
|
|
163
|
+
<Grid {rows} {columns} height={640} footer />
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Pass `pinnedRows` to keep rows stuck to the **top**, always visible above the
|
|
167
|
+
scroll — a benchmark, a summary, or "your position". They render with the normal
|
|
168
|
+
columns (and `rowClass`) but are display-only:
|
|
169
|
+
|
|
170
|
+
```svelte
|
|
171
|
+
<Grid {rows} {columns} height={640} pinnedRows={[benchmark]} />
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Row selection
|
|
175
|
+
|
|
176
|
+
Set `rowSelection` for a leading checkbox column — whole-row selection keyed by
|
|
177
|
+
`row.id`, so it survives sorting and filtering (unlike the positional cell
|
|
178
|
+
selection above). The header checkbox selects/clears all matching rows.
|
|
179
|
+
`onRowSelectionChange` reports the selected ids:
|
|
180
|
+
|
|
181
|
+
```svelte
|
|
182
|
+
<Grid
|
|
183
|
+
{rows}
|
|
184
|
+
{columns}
|
|
185
|
+
height={640}
|
|
186
|
+
rowSelection
|
|
187
|
+
onRowSelectionChange={(ids) => (selected = ids)}
|
|
188
|
+
/>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
In server (`source`) mode the per-row checkboxes work on loaded rows; the
|
|
192
|
+
select-all header checkbox is disabled (unloaded ids can't be enumerated).
|
|
193
|
+
|
|
194
|
+
Selection keys off `row.id` by default; pass `getRowId` for string/UUID/composite
|
|
195
|
+
keys (`getRowId={(r) => r.uuid}`).
|
|
196
|
+
|
|
197
|
+
## Grouping
|
|
198
|
+
|
|
199
|
+
Pass `groupBy` (column keys) to group rows — single or nested. Groups are
|
|
200
|
+
collapsible (click the header) and show **live subtotals** under any column with
|
|
201
|
+
a `groupAgg` set:
|
|
202
|
+
|
|
203
|
+
```svelte
|
|
204
|
+
<script>
|
|
205
|
+
const columns = [
|
|
206
|
+
{ type: 'price', key: 'price', header: 'Price', groupAgg: 'avg' },
|
|
207
|
+
{ type: 'volume', key: 'volume', header: 'Volume', groupAgg: 'sum' },
|
|
208
|
+
// …
|
|
209
|
+
];
|
|
210
|
+
</script>
|
|
211
|
+
|
|
212
|
+
<Grid {rows} {columns} groupBy={['sector', 'exchange']} height={640} />
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Group headers are the same height as data rows, so virtual scrolling stays smooth
|
|
216
|
+
over very large grouped sets. Subtotals recompute live as the feed ticks, and the
|
|
217
|
+
current group's header stays pinned to the top as you scroll within it.
|
|
218
|
+
|
|
219
|
+
## Theming
|
|
220
|
+
|
|
221
|
+
Dark-first and self-contained — no CSS import required. Use the `theme` prop with
|
|
222
|
+
a built-in preset or a custom token map:
|
|
223
|
+
|
|
224
|
+
```svelte
|
|
225
|
+
<Grid {rows} {columns} theme="light" height={640} />
|
|
226
|
+
<Grid {rows} {columns} theme={{ bg: '#0b1020', up: '#22d3ee' }} height={640} />
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Built-in `darkTheme` / `lightTheme` are exported (`GridTheme`). Or set any
|
|
230
|
+
`--bo-grid-*` custom property on an ancestor — the prop is just a convenience over
|
|
231
|
+
these:
|
|
232
|
+
|
|
233
|
+
```css
|
|
234
|
+
.my-app {
|
|
235
|
+
--bo-grid-bg: #fff;
|
|
236
|
+
--bo-grid-text: #1a1a1a;
|
|
237
|
+
--bo-grid-up: #16a34a;
|
|
238
|
+
--bo-grid-down: #dc2626;
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
## Server-side / large datasets
|
|
243
|
+
|
|
244
|
+
Instead of an in-memory `rows` array, back the grid with a **`RowSource`** — the
|
|
245
|
+
grid requests only the visible window (plus overscan), so the dataset can be far
|
|
246
|
+
larger than memory. Sort and filter are delegated to the source; unloaded rows
|
|
247
|
+
render as skeletons.
|
|
248
|
+
|
|
249
|
+
```svelte
|
|
250
|
+
<script lang="ts">
|
|
251
|
+
import { Grid, createArraySource, type RowSource } from 'bo-grid';
|
|
252
|
+
|
|
253
|
+
// Your own source: fetch a window from the server.
|
|
254
|
+
const source: RowSource = {
|
|
255
|
+
async getRows({ range, sort, filter }) {
|
|
256
|
+
const res = await fetch(`/api/rows?offset=${range.start}&limit=${range.end - range.start}` +
|
|
257
|
+
`&sort=${sort?.key ?? ''}&dir=${sort?.dir ?? ''}&q=${filter}`);
|
|
258
|
+
return res.json(); // { rows, total }
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
</script>
|
|
262
|
+
|
|
263
|
+
<Grid {columns} {source} height={640} />
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`createArraySource(rows, { latency, filterKeys })` adapts an in-memory array to
|
|
267
|
+
the same interface (handy for testing the path or client-side data). Grouping is
|
|
268
|
+
client-only, so it's not applied in source mode.
|
|
269
|
+
|
|
270
|
+
## Column reorder
|
|
271
|
+
|
|
272
|
+
Drag any column header to reorder columns. Pass `persistKey` to remember the
|
|
273
|
+
user's order across reloads (saved to `localStorage`):
|
|
274
|
+
|
|
275
|
+
```svelte
|
|
276
|
+
<Grid {rows} {columns} persistKey="watchlist" height={640} />
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Column resize
|
|
280
|
+
|
|
281
|
+
Drag the grip on a header's right edge to resize a column; **double-click** the
|
|
282
|
+
grip to reset it to its default width. Resizing a fit-to-width (`flex`) column
|
|
283
|
+
pins it to the dragged width and lets its neighbours absorb the difference. The
|
|
284
|
+
same `persistKey` remembers widths across reloads.
|
|
285
|
+
|
|
286
|
+
Resizing is on by default. Turn it off for the whole grid with `resizable={false}`,
|
|
287
|
+
or per column with `resizable: false` (handy for a fixed action column):
|
|
288
|
+
|
|
289
|
+
```svelte
|
|
290
|
+
<Grid {rows} {columns} resizable={false} height={640} />
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Column visibility
|
|
294
|
+
|
|
295
|
+
Pass `hiddenColumns` (column keys to hide) — controlled, like `filter`. Build
|
|
296
|
+
your own column-picker UI and drive the prop; the grid stays presentation-only:
|
|
297
|
+
|
|
298
|
+
```svelte
|
|
299
|
+
<Grid {rows} {columns} hiddenColumns={['bonus', 'rating']} height={640} />
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Header groups
|
|
303
|
+
|
|
304
|
+
Give columns a `group` label to render a spanning parent header over consecutive
|
|
305
|
+
columns that share it (works best with fixed-width columns):
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
const columns = [
|
|
309
|
+
{ type: 'text', key: 'symbol', header: 'Symbol', width: 120, group: 'Holding' },
|
|
310
|
+
{ type: 'number', key: 'shares', header: 'Shares', width: 90, group: 'Holding' },
|
|
311
|
+
{ type: 'price', key: 'last', header: 'Last', width: 90, group: 'Pricing' },
|
|
312
|
+
];
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Per-row styling
|
|
316
|
+
|
|
317
|
+
Return a class from `rowClass` to style rows by their data (e.g. red/green book
|
|
318
|
+
levels). Rows live inside the grid, so target the class with `:global(...)`:
|
|
319
|
+
|
|
320
|
+
```svelte
|
|
321
|
+
<Grid {rows} {columns} height={640} rowClass={(r) => (r.up ? 'gain' : 'loss')} />
|
|
322
|
+
|
|
323
|
+
<style>
|
|
324
|
+
:global(.bo-grid .row.gain) { color: var(--up); }
|
|
325
|
+
:global(.bo-grid .row.loss) { color: var(--down); }
|
|
326
|
+
</style>
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
`onRowClick(row, event)` fires when a row is activated by click or <kbd>Enter</kbd>
|
|
330
|
+
on the focused cell — wire it to open a detail view or navigate.
|
|
331
|
+
|
|
332
|
+
## Inline editing
|
|
333
|
+
|
|
334
|
+
Mark a column `editable: true`. Double-click a cell (or press <kbd>Enter</kbd> on
|
|
335
|
+
the focused cell) to edit; <kbd>Enter</kbd>/blur commits, <kbd>Esc</kbd> cancels.
|
|
336
|
+
The grid is controlled, so it reports the change via `onCellEdit` — update your
|
|
337
|
+
own row data there:
|
|
338
|
+
|
|
339
|
+
```svelte
|
|
340
|
+
<Grid
|
|
341
|
+
{rows}
|
|
342
|
+
{columns}
|
|
343
|
+
height={640}
|
|
344
|
+
onCellEdit={(e) => (e.row[e.column.key] = e.value)}
|
|
345
|
+
/>
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
`e.value` is parsed to a number for numeric columns (invalid input is rejected),
|
|
349
|
+
otherwise the raw string. Make the edited field `$state` so the cell updates. Add
|
|
350
|
+
a column `validate(value, row)` to reject edits that fail your own rule (it
|
|
351
|
+
applies to paste too):
|
|
352
|
+
|
|
353
|
+
```ts
|
|
354
|
+
{ type: 'number', key: 'qty', header: 'Qty', editable: true, validate: (v) => v >= 0 }
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## Pinned columns
|
|
358
|
+
|
|
359
|
+
Set `pinned: true` on a column to keep it visible while the rest scroll
|
|
360
|
+
horizontally. This is opt-in: with no pinned columns the grid stays fit-to-width
|
|
361
|
+
(no horizontal scroll). Pinned columns move to the left edge and stick there.
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
const columns = [
|
|
365
|
+
{ type: 'text', key: 'symbol', header: 'Symbol', width: 132, pinned: true },
|
|
366
|
+
{ type: 'price', key: 'price', header: 'Price', width: 88, pinned: true },
|
|
367
|
+
// …wider columns scroll under the pinned ones
|
|
368
|
+
];
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
## Export
|
|
372
|
+
|
|
373
|
+
CSV export is dependency-free:
|
|
374
|
+
|
|
375
|
+
```ts
|
|
376
|
+
import { exportCSV, toCSV } from 'bo-grid';
|
|
377
|
+
|
|
378
|
+
exportCSV('tickers.csv', rows, columns); // triggers a download
|
|
379
|
+
const text = toCSV(rows, columns, { formatted: true }); // or get the string
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Excel export loads SheetJS via **dynamic import**, so it lands in its own lazy
|
|
383
|
+
chunk and never bloats your core bundle. `xlsx` is an **optional peer dependency**
|
|
384
|
+
— install it only if you use this:
|
|
385
|
+
|
|
386
|
+
```ts
|
|
387
|
+
import { exportXLSX } from 'bo-grid';
|
|
388
|
+
await exportXLSX('tickers.xlsx', rows, columns); // npm i xlsx
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
Sparkline columns are skipped; numeric columns export as raw numbers so
|
|
392
|
+
spreadsheets can compute on them (pass `{ formatted: true }` for display strings).
|
|
393
|
+
Ctrl/⌘+C still copies the current selection as TSV.
|
|
394
|
+
|
|
395
|
+
## Also exported
|
|
396
|
+
|
|
397
|
+
`Sparkline` component · `drawCandles` / `setupHiDpiCanvas` (draw on your own
|
|
398
|
+
canvas) · `fmtPrice` / `fmtPercent` / `fmtVolume` / `fmtDate` · `heatColor` ·
|
|
399
|
+
`Selection` · `aggregate` · `toCSV` / `exportCSV` / `exportXLSX` / `rowsToMatrix`.
|
|
400
|
+
|
|
401
|
+
## Pivot tables
|
|
402
|
+
|
|
403
|
+
`pivot()` transforms flat rows into a pivot table (rows + dynamic columns) you
|
|
404
|
+
hand straight to `<Grid>` — group by row fields, spread a field's values into
|
|
405
|
+
columns, and aggregate a measure into each cell:
|
|
406
|
+
|
|
407
|
+
```svelte
|
|
408
|
+
<script lang="ts">
|
|
409
|
+
import { Grid, pivot } from 'bo-grid';
|
|
410
|
+
|
|
411
|
+
const { rows: pivotRows, columns: pivotColumns } = pivot(data, {
|
|
412
|
+
rowFields: ['sector'], // → leading text columns
|
|
413
|
+
columnField: 'exchange', // distinct values → columns (+ a Total)
|
|
414
|
+
measure: 'volume',
|
|
415
|
+
agg: 'sum',
|
|
416
|
+
});
|
|
417
|
+
</script>
|
|
418
|
+
|
|
419
|
+
<Grid rows={pivotRows} columns={pivotColumns} height={640} />
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
It's a pure function, so call it as a snapshot or reactively as you prefer.
|
|
423
|
+
|
|
424
|
+
## Accessibility
|
|
425
|
+
|
|
426
|
+
The grid follows the ARIA grid pattern. Because rows are virtualized, it exposes
|
|
427
|
+
the real dimensions and positions so assistive tech isn't misled:
|
|
428
|
+
|
|
429
|
+
- `role="grid"` with `aria-rowcount` / `aria-colcount` (full size, not the
|
|
430
|
+
rendered window) and `aria-multiselectable`.
|
|
431
|
+
- `role="row"` + `aria-rowindex` on rows, `role="gridcell"` + `aria-colindex` +
|
|
432
|
+
`aria-selected` on cells, `role="columnheader"` + `aria-sort` on headers.
|
|
433
|
+
- `aria-activedescendant` tracks the focused cell for screen readers.
|
|
434
|
+
- Sparkline cells carry a text `aria-label`; sticky/skeleton duplicates are
|
|
435
|
+
`aria-hidden`; the aggregation bar is an `aria-live` status region.
|
|
436
|
+
- Fully keyboard-operable (see [Selection & keyboard](#selection--keyboard) and
|
|
437
|
+
inline editing) and respects `prefers-reduced-motion`.
|
|
438
|
+
|
|
439
|
+
A formal WCAG 2.1 AA audit is on the roadmap; the above is a deliberate pass, not
|
|
440
|
+
a certification.
|
|
441
|
+
|
|
442
|
+
## Develop
|
|
443
|
+
|
|
444
|
+
```sh
|
|
445
|
+
pnpm install
|
|
446
|
+
pnpm dev # demo/playground at http://localhost:5180
|
|
447
|
+
pnpm test # unit tests (Vitest)
|
|
448
|
+
pnpm check # type-check
|
|
449
|
+
pnpm smoke # headless mount + interaction smoke test
|
|
450
|
+
pnpm size # bundle-size budget
|
|
451
|
+
pnpm package # build the publishable library into dist/
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
## Roadmap
|
|
455
|
+
|
|
456
|
+
Formal WCAG 2.1 AA audit → multi-measure pivots → more themes. Contributions
|
|
457
|
+
welcome.
|
|
458
|
+
|
|
459
|
+
## License
|
|
460
|
+
|
|
461
|
+
MIT
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function fmtPrice(v: number): string;
|
|
2
|
+
export declare function fmtPercent(v: number): string;
|
|
3
|
+
export declare function fmtVolume(v: number): string;
|
|
4
|
+
export type DateStyle = 'short' | 'medium';
|
|
5
|
+
export declare function fmtDate(ms: number, style?: DateStyle): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Built-in formatters (proposal Phase 1 §Ticker column + formatting).
|
|
2
|
+
export function fmtPrice(v) {
|
|
3
|
+
return v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
4
|
+
}
|
|
5
|
+
export function fmtPercent(v) {
|
|
6
|
+
const sign = v > 0 ? '+' : '';
|
|
7
|
+
return `${sign}${v.toFixed(2)}%`;
|
|
8
|
+
}
|
|
9
|
+
export function fmtVolume(v) {
|
|
10
|
+
if (v >= 1_000_000_000)
|
|
11
|
+
return `${(v / 1_000_000_000).toFixed(2)}B`;
|
|
12
|
+
if (v >= 1_000_000)
|
|
13
|
+
return `${(v / 1_000_000).toFixed(2)}M`;
|
|
14
|
+
if (v >= 1_000)
|
|
15
|
+
return `${(v / 1_000).toFixed(1)}K`;
|
|
16
|
+
return `${v}`;
|
|
17
|
+
}
|
|
18
|
+
export function fmtDate(ms, style = 'medium') {
|
|
19
|
+
if (!Number.isFinite(ms))
|
|
20
|
+
return '';
|
|
21
|
+
const opts = style === 'short'
|
|
22
|
+
? { month: 'numeric', day: 'numeric', year: '2-digit' }
|
|
23
|
+
: { month: 'short', day: 'numeric', year: 'numeric' };
|
|
24
|
+
return new Date(ms).toLocaleDateString('en-US', opts);
|
|
25
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { AggKind, AggResult } from './aggregate';
|
|
3
|
+
import { AGG_LABELS } from './aggregate';
|
|
4
|
+
|
|
5
|
+
let { result, kinds }: { result: AggResult | null; kinds: AggKind[] } = $props();
|
|
6
|
+
|
|
7
|
+
function fmt(v: number): string {
|
|
8
|
+
return v.toLocaleString('en-US', { maximumFractionDigits: 2 });
|
|
9
|
+
}
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
{#if result}
|
|
13
|
+
<div class="agg" role="status" aria-live="polite">
|
|
14
|
+
{#each kinds as k (k)}
|
|
15
|
+
<span class="stat">
|
|
16
|
+
<span class="k">{AGG_LABELS[k]}</span>
|
|
17
|
+
<span class="v">{k === 'count' ? result.count : fmt(result[k])}</span>
|
|
18
|
+
</span>
|
|
19
|
+
{/each}
|
|
20
|
+
</div>
|
|
21
|
+
{/if}
|
|
22
|
+
|
|
23
|
+
<style>
|
|
24
|
+
.agg {
|
|
25
|
+
display: flex;
|
|
26
|
+
gap: 18px;
|
|
27
|
+
align-items: center;
|
|
28
|
+
height: 26px;
|
|
29
|
+
padding: 0 10px;
|
|
30
|
+
background: var(--bo-header-bg);
|
|
31
|
+
border-top: 0.5px solid var(--bo-border);
|
|
32
|
+
font-family: var(--bo-mono);
|
|
33
|
+
font-size: 11px;
|
|
34
|
+
font-variant-numeric: tabular-nums;
|
|
35
|
+
white-space: nowrap;
|
|
36
|
+
overflow: hidden;
|
|
37
|
+
}
|
|
38
|
+
.k {
|
|
39
|
+
color: var(--bo-text-dim);
|
|
40
|
+
margin-right: 5px;
|
|
41
|
+
}
|
|
42
|
+
.v {
|
|
43
|
+
color: var(--bo-text);
|
|
44
|
+
}
|
|
45
|
+
</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AggKind, AggResult } from './aggregate';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
result: AggResult | null;
|
|
4
|
+
kinds: AggKind[];
|
|
5
|
+
};
|
|
6
|
+
declare const AggregationBar: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type AggregationBar = ReturnType<typeof AggregationBar>;
|
|
8
|
+
export default AggregationBar;
|