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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +461 -0
  3. package/dist/format/format.d.ts +5 -0
  4. package/dist/format/format.js +25 -0
  5. package/dist/grid/AggregationBar.svelte +45 -0
  6. package/dist/grid/AggregationBar.svelte.d.ts +8 -0
  7. package/dist/grid/Cell.svelte +230 -0
  8. package/dist/grid/Cell.svelte.d.ts +32 -0
  9. package/dist/grid/Grid.svelte +1339 -0
  10. package/dist/grid/Grid.svelte.d.ts +76 -0
  11. package/dist/grid/GroupRow.svelte +110 -0
  12. package/dist/grid/GroupRow.svelte.d.ts +11 -0
  13. package/dist/grid/aggregate.d.ts +11 -0
  14. package/dist/grid/aggregate.js +23 -0
  15. package/dist/grid/clipboard.d.ts +9 -0
  16. package/dist/grid/clipboard.js +24 -0
  17. package/dist/grid/column.d.ts +95 -0
  18. package/dist/grid/column.js +62 -0
  19. package/dist/grid/export-xlsx.d.ts +8 -0
  20. package/dist/grid/export-xlsx.js +19 -0
  21. package/dist/grid/export.d.ts +19 -0
  22. package/dist/grid/export.js +48 -0
  23. package/dist/grid/grouping.d.ts +36 -0
  24. package/dist/grid/grouping.js +62 -0
  25. package/dist/grid/heatmap.d.ts +1 -0
  26. package/dist/grid/heatmap.js +12 -0
  27. package/dist/grid/pin.d.ts +23 -0
  28. package/dist/grid/pin.js +24 -0
  29. package/dist/grid/pivot.d.ts +27 -0
  30. package/dist/grid/pivot.js +0 -0
  31. package/dist/grid/reorder.d.ts +2 -0
  32. package/dist/grid/reorder.js +10 -0
  33. package/dist/grid/rowheight.d.ts +17 -0
  34. package/dist/grid/rowheight.js +41 -0
  35. package/dist/grid/selection.svelte.d.ts +30 -0
  36. package/dist/grid/selection.svelte.js +64 -0
  37. package/dist/grid/sizing.d.ts +17 -0
  38. package/dist/grid/sizing.js +28 -0
  39. package/dist/grid/source.d.ts +43 -0
  40. package/dist/grid/source.js +29 -0
  41. package/dist/grid/source.svelte.d.ts +21 -0
  42. package/dist/grid/source.svelte.js +53 -0
  43. package/dist/grid/theme.d.ts +27 -0
  44. package/dist/grid/theme.js +60 -0
  45. package/dist/index.d.ts +19 -0
  46. package/dist/index.js +24 -0
  47. package/dist/sparkline/Sparkline.svelte +74 -0
  48. package/dist/sparkline/Sparkline.svelte.d.ts +9 -0
  49. package/dist/sparkline/sparkline-render.d.ts +16 -0
  50. package/dist/sparkline/sparkline-render.js +83 -0
  51. package/dist/types.d.ts +7 -0
  52. package/dist/types.js +1 -0
  53. 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;