bo-grid 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,26 +1,30 @@
1
1
  # bo-grid
2
2
 
3
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.
4
+ realtime cell updates, and virtual scrolling in a package that gzips to ~20 KB.
5
5
  A free alternative to the heavyweight grids that paywall these features.
6
6
 
7
7
  **[Live demo](https://bonguynvan.github.io/bo-grid/)** ·
8
- **[API reference](https://bonguynvan.github.io/bo-grid/api.html)**
8
+ **[API reference](https://bonguynvan.github.io/bo-grid/api.html)** ·
9
+ **[Benchmarks](./BENCHMARKS.md)**
9
10
 
10
- The demo is a small gallery of seven grid types — a realtime **Trading desk**, a
11
+ The demo is a small gallery of nine grid types — a realtime **Trading desk**, a
11
12
  grouped **Portfolio** with subtotals and pivot, a general-purpose editable
12
13
  **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
14
+ matrix, a **Leaderboard** with rank medals and score bars, a **Tree** file
15
+ explorer, a drag-to-reorder **Tasks** list, and a **1M-row** trade tape windowed
16
+ from a synthetic source — switch between them with the tabs.
17
+
18
+ > **Status: v0.2.** Working: config-driven columns, virtual scroll, client
19
+ > single/multi/controlled sort + global & per-column filters, multi-cell selection
20
+ > + live range aggregation, row grouping (nested, collapsible, sticky headers,
21
+ > live subtotals), pivot tables, tree data, master-detail, a server-side
19
22
  > `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
23
+ > drag-to-resize columns, pinned columns (left/right), row selection, pagination,
24
+ > inline editing with validation, type-to-edit, clipboard copy/paste, sparklines,
25
+ > realtime flash, heatmaps, theming, and full keyboard a11y. **SSR/SvelteKit-safe.**
26
+ > Unit tests (Vitest), type-check, a headless mount smoke-test, an SSR render
27
+ > check, and library + demo bundle-size budgets all run in CI. A formal WCAG audit
24
28
  > is the main thing left — see the roadmap.
25
29
 
26
30
  ## Why
@@ -30,9 +34,14 @@ tape windowed from a synthetic source — switch between them with the tabs.
30
34
  | Price | $$$ / dev / year | Free (MIT) |
31
35
  | Sparklines | paid tier | built in |
32
36
  | Realtime cell updates | DIY / complex | built-in primitive |
33
- | Bundle | ~500 KB | a few KB gzip |
37
+ | Bundle | hundreds of KB | **~20 KB gzip** ([benchmarks](./BENCHMARKS.md)) |
34
38
  | Svelte | wrapper | native Svelte 5 |
35
39
 
40
+ bo-grid ships most of AG Grid's **paid (Enterprise)** features — grouping, pivot,
41
+ tree data, master-detail, range selection, Excel export, sparklines — for free.
42
+ See the honest **[bo-grid vs AG Grid](./docs/vs-ag-grid.md)** comparison (including
43
+ what it doesn't do) to decide.
44
+
36
45
  ## Install
37
46
 
38
47
  ```sh
@@ -40,6 +49,12 @@ npm i bo-grid
40
49
  # peer dependency: svelte@^5
41
50
  ```
42
51
 
52
+ Works with **SvelteKit / SSR** out of the box — `<Grid>` server-renders to HTML
53
+ without touching `window`/`document`/`localStorage` (a CI gate, `pnpm ssr`,
54
+ proves it). The package is `sideEffects: false`, so unused exports tree-shake
55
+ away. See the **[SvelteKit guide](./docs/sveltekit.md)** for `load`-function data,
56
+ realtime feeds, and layout persistence.
57
+
43
58
  ## Usage
44
59
 
45
60
  ```svelte
@@ -112,6 +127,15 @@ a function for variable per-row heights (in-memory mode):
112
127
  Variable heights use a prefix-sum + binary-search virtualizer, so scrolling stays
113
128
  O(log n). Source mode is uniform-only (unloaded row heights aren't known).
114
129
 
130
+ ## Pagination
131
+
132
+ Prefer pages over one long scroll? Set `pageSize` (> 0) for a paged view with a
133
+ first/prev/next/last pager; rows still virtualize within each page. In-memory mode.
134
+
135
+ ```svelte
136
+ <Grid {rows} {columns} height={640} pageSize={25} />
137
+ ```
138
+
115
139
  ## Sort & filter
116
140
 
117
141
  Click a column header to sort (asc → desc → off). **Shift-click** additional
@@ -175,7 +199,8 @@ columns (and `rowClass`) but are display-only:
175
199
 
176
200
  Set `rowSelection` for a leading checkbox column — whole-row selection keyed by
177
201
  `row.id`, so it survives sorting and filtering (unlike the positional cell
178
- selection above). The header checkbox selects/clears all matching rows.
202
+ selection above). The header checkbox selects/clears all matching rows, and
203
+ <kbd>Space</kbd> toggles the focused row from the keyboard.
179
204
  `onRowSelectionChange` reports the selected ids:
180
205
 
181
206
  ```svelte
@@ -269,8 +294,10 @@ client-only, so it's not applied in source mode.
269
294
 
270
295
  ## Column reorder
271
296
 
272
- Drag any column header to reorder columns. Pass `persistKey` to remember the
273
- user's order across reloads (saved to `localStorage`):
297
+ Pass `onRowReorder(from, to)` to enable **drag-to-reorder rows** via a handle in
298
+ the first column reorder your own `rows` array in the callback (flat, unsorted,
299
+ in-memory lists). Drag any column header to reorder columns; pass `persistKey` to
300
+ remember the user's order across reloads (saved to `localStorage`):
274
301
 
275
302
  ```svelte
276
303
  <Grid {rows} {columns} persistKey="watchlist" height={640} />
@@ -283,8 +310,9 @@ grip to reset it to its default width. Resizing a fit-to-width (`flex`) column
283
310
  pins it to the dragged width and lets its neighbours absorb the difference. The
284
311
  same `persistKey` remembers widths across reloads.
285
312
 
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):
313
+ Bound a column's draggable range with `minWidth` / `maxWidth`. Resizing is on by
314
+ default. Turn it off for the whole grid with `resizable={false}`, or per column
315
+ with `resizable: false` (handy for a fixed action column):
288
316
 
289
317
  ```svelte
290
318
  <Grid {rows} {columns} resizable={false} height={640} />
@@ -312,6 +340,33 @@ const columns = [
312
340
  ];
313
341
  ```
314
342
 
343
+ ## Tree data
344
+
345
+ Pass `getChildren` to render hierarchical rows — `rows` become the roots, and each
346
+ node gets an indented first column with an expand chevron when it has children:
347
+
348
+ ```svelte
349
+ <Grid {rows} {columns} height={520} getChildren={(r) => r.children} />
350
+ ```
351
+
352
+ In tree mode the grid renders the tree directly (filter/sort/group/paginate are
353
+ not applied to it). Nodes are keyboard-accessible: **→** expands a collapsed node,
354
+ **←** collapses an expanded one, and rows expose `aria-level` / `aria-expanded`.
355
+
356
+ ## Master-detail
357
+
358
+ Pass a `detail` snippet to render an expandable panel under each row — the grid
359
+ adds a leading expand toggle and virtualizes the expanded heights (`detailHeight`,
360
+ default 160). In-memory mode:
361
+
362
+ ```svelte
363
+ <Grid {rows} {columns} height={640} detailHeight={120} detail={rowDetail} />
364
+
365
+ {#snippet rowDetail({ row })}
366
+ <div class="detail">…anything about {row.name}…</div>
367
+ {/snippet}
368
+ ```
369
+
315
370
  ## Per-row styling
316
371
 
317
372
  Return a class from `rowClass` to style rows by their data (e.g. red/green book
@@ -326,15 +381,35 @@ levels). Rows live inside the grid, so target the class with `:global(...)`:
326
381
  </style>
327
382
  ```
328
383
 
384
+ For per-column styling, a column's `cellClass` (static or `(value, row)`
385
+ conditional) and `headerClass` add classes to that column's cells/header:
386
+
387
+ ```ts
388
+ { type: 'number', key: 'pnl', header: 'P&L',
389
+ cellClass: (v) => (Number(v) < 0 ? 'loss' : 'gain'), headerClass: 'num-head' }
390
+ ```
391
+
329
392
  `onRowClick(row, event)` fires when a row is activated by click or <kbd>Enter</kbd>
330
393
  on the focused cell — wire it to open a detail view or navigate.
331
394
 
395
+ Pass `rowMenu(row)` to add a **right-click menu** of row actions; each item runs
396
+ its `onSelect` and the menu closes (also on outside-click or <kbd>Esc</kbd>). It
397
+ is keyboard-accessible: the <kbd>ContextMenu</kbd> key (or
398
+ <kbd>Shift</kbd>+<kbd>F10</kbd>) opens it at the focused cell.
399
+
400
+ ```svelte
401
+ <Grid {rows} {columns} height={640}
402
+ rowMenu={(r) => [{ label: 'Delete', onSelect: () => remove(r.id) }]} />
403
+ ```
404
+
332
405
  ## Inline editing
333
406
 
334
407
  Mark a column `editable: true`. Double-click a cell (or press <kbd>Enter</kbd> on
335
408
  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:
409
+ Or just start typing a printable key on a focused editable cell opens the
410
+ editor seeded with that character (Excel-style type-to-edit). The grid is
411
+ controlled, so it reports the change via `onCellEdit` — update your own row data
412
+ there:
338
413
 
339
414
  ```svelte
340
415
  <Grid
@@ -354,17 +429,27 @@ applies to paste too):
354
429
  { type: 'number', key: 'qty', header: 'Qty', editable: true, validate: (v) => v >= 0 }
355
430
  ```
356
431
 
432
+ Give an editable column `options` to edit it via a dropdown instead of a text
433
+ input (enum/status columns):
434
+
435
+ ```ts
436
+ { type: 'text', key: 'status', header: 'Status', editable: true,
437
+ options: ['New', 'Active', 'Closed'] }
438
+ ```
439
+
357
440
  ## Pinned columns
358
441
 
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.
442
+ Set `pinned: true` (or `'left'`) on a column to keep it visible while the rest
443
+ scroll horizontally; `pinned: 'right'` sticks it to the right edge (e.g. an
444
+ actions or total column). Opt-in: with no pinned columns the grid stays
445
+ fit-to-width (no horizontal scroll).
362
446
 
363
447
  ```ts
364
448
  const columns = [
365
449
  { type: 'text', key: 'symbol', header: 'Symbol', width: 132, pinned: true },
366
450
  { type: 'price', key: 'price', header: 'Price', width: 88, pinned: true },
367
- // …wider columns scroll under the pinned ones
451
+ // …wider columns scroll under the pinned ones
452
+ { type: 'number', key: 'pnl', header: 'P&L', width: 96, pinned: 'right' },
368
453
  ];
369
454
  ```
370
455
 
@@ -13,13 +13,17 @@
13
13
  selected = false,
14
14
  focused = false,
15
15
  pinned = false,
16
- pinLeft = 0,
16
+ pinSide = 'left',
17
+ pinOffset = 0,
17
18
  width,
18
19
  alt = false,
19
20
  editing = false,
21
+ seed = null,
20
22
  colIndex,
21
23
  cellId,
22
24
  cellSnippet,
25
+ tree,
26
+ dragHandle,
23
27
  onCellDown,
24
28
  onCellEnter,
25
29
  onCellClick,
@@ -34,14 +38,22 @@
34
38
  selected?: boolean;
35
39
  focused?: boolean;
36
40
  pinned?: boolean;
37
- pinLeft?: number;
41
+ pinSide?: 'left' | 'right';
42
+ pinOffset?: number;
38
43
  /** Fixed pixel width (pinned/horizontal-scroll mode). */
39
44
  width?: number;
40
45
  alt?: boolean;
41
46
  editing?: boolean;
47
+ /** Type-to-edit seed: when set, the editor opens pre-filled with this string
48
+ (the character that triggered the edit) instead of the current value. */
49
+ seed?: string | null;
42
50
  colIndex?: number;
43
51
  cellId?: string;
44
52
  cellSnippet?: Snippet<[{ row: GridRow; column: ColumnDef; value: unknown }]>;
53
+ /** Tree-data gutter for the first column: indent + expand chevron. */
54
+ tree?: { depth: number; hasChildren: boolean; expanded: boolean; onToggle: () => void };
55
+ /** Drag-to-reorder handle for the first column (HTML5 draggable grip). */
56
+ dragHandle?: { onStart: () => void; onEnd: () => void };
45
57
  onCellDown?: (r: number, c: number, e: PointerEvent) => void;
46
58
  onCellEnter?: (r: number, c: number, e: PointerEvent) => void;
47
59
  onCellClick?: (r: number, c: number, e: MouseEvent) => void;
@@ -53,7 +65,13 @@
53
65
  let cancelled = false;
54
66
  function focusSelect(node: HTMLInputElement) {
55
67
  node.focus();
56
- node.select();
68
+ // Type-to-edit: keep the seeded character and put the caret after it;
69
+ // otherwise select the whole value so the next keystroke replaces it.
70
+ if (seed != null) node.setSelectionRange(node.value.length, node.value.length);
71
+ else node.select();
72
+ }
73
+ function focusEl(node: HTMLElement) {
74
+ node.focus();
57
75
  }
58
76
  function onEditKey(e: KeyboardEvent) {
59
77
  e.stopPropagation(); // keep arrows/Enter in the input, not the grid
@@ -78,12 +96,22 @@
78
96
  // though the key is only known at runtime.
79
97
  const value = $derived(row[col.key]);
80
98
  const kind = $derived(col.type === 'text' ? 'text' : col.type === 'sparkline' ? 'spark' : 'num');
99
+ // Optional per-column cell class (static string or value/row function).
100
+ const extraClass = $derived(
101
+ typeof col.cellClass === 'function' ? (col.cellClass(value, row) ?? '') : (col.cellClass ?? ''),
102
+ );
103
+ // Native tooltip of the full value (opt-in via column `tooltip`).
104
+ const tip = $derived(
105
+ col.tooltip && col.type !== 'sparkline' && col.type !== 'custom'
106
+ ? formatCell(col, value, row)
107
+ : undefined,
108
+ );
81
109
 
82
110
  function cellStyle(): string {
83
111
  let s = width != null ? `flex:0 0 ${width}px;width:${width}px;` : colStyle(col);
84
112
  if (col.type === 'heatmap') s += `background:${heatColor(Number(value), col.min, col.max)};`;
85
113
  if (pinned) {
86
- s += `position:sticky;left:${pinLeft}px;z-index:1;`;
114
+ s += `position:sticky;${pinSide}:${pinOffset}px;z-index:1;`;
87
115
  // Pinned cells must be opaque to cover scrolled content. Heatmap already
88
116
  // set a background; otherwise match the (alternating) row colour.
89
117
  if (col.type !== 'heatmap') s += `background:var(${alt ? '--bo-row-a' : '--bo-row-b'});`;
@@ -95,7 +123,7 @@
95
123
  <!-- Keyboard interaction is handled at the grid level (arrow nav via aria-activedescendant + Enter); this cell click is a pointer affordance. -->
96
124
  <!-- svelte-ignore a11y_click_events_have_key_events -->
97
125
  <span
98
- class="c {kind}"
126
+ class="c {kind} {extraClass}"
99
127
  class:dim={col.type === 'volume'}
100
128
  class:pos={col.type === 'percent' && Number(value) >= 0}
101
129
  class:neg={col.type === 'percent' && Number(value) < 0}
@@ -105,6 +133,7 @@
105
133
  role="gridcell"
106
134
  tabindex="-1"
107
135
  id={cellId}
136
+ title={tip}
108
137
  aria-colindex={colIndex}
109
138
  aria-selected={selected}
110
139
  onpointerdown={(e) => onCellDown?.(r, c, e)}
@@ -112,10 +141,57 @@
112
141
  onclick={(e) => onCellClick?.(r, c, e)}
113
142
  ondblclick={() => onCellDblClick?.(r, c)}
114
143
  >
115
- {#if editing}
116
- <input
144
+ {#if dragHandle}
145
+ <span
146
+ class="drag-handle"
147
+ role="button"
148
+ tabindex="-1"
149
+ aria-label="Drag to reorder row"
150
+ draggable="true"
151
+ onpointerdown={(e) => e.stopPropagation()}
152
+ ondragstart={() => dragHandle.onStart()}
153
+ ondragend={() => dragHandle.onEnd()}
154
+ >⠿</span>
155
+ {/if}
156
+ {#if tree}
157
+ <span class="tree-gutter" style="padding-left:{tree.depth * 16}px">
158
+ {#if tree.hasChildren}
159
+ <button
160
+ class="tree-toggle"
161
+ type="button"
162
+ aria-expanded={tree.expanded}
163
+ aria-label="Toggle children"
164
+ onpointerdown={(e) => e.stopPropagation()}
165
+ onclick={(e) => {
166
+ e.stopPropagation();
167
+ tree.onToggle();
168
+ }}
169
+ >
170
+ {tree.expanded ? '▾' : '▸'}
171
+ </button>
172
+ {:else}
173
+ <span class="tree-leaf"></span>
174
+ {/if}
175
+ </span>
176
+ {/if}
177
+ {#if editing && col.options && col.options.length > 0}
178
+ <select
117
179
  class="bo-edit"
118
180
  value={String(value ?? '')}
181
+ use:focusEl
182
+ onkeydown={onEditKey}
183
+ onblur={onEditBlur}
184
+ onpointerdown={(e) => e.stopPropagation()}
185
+ onclick={(e) => e.stopPropagation()}
186
+ >
187
+ {#each col.options as opt (opt)}
188
+ <option value={opt}>{opt}</option>
189
+ {/each}
190
+ </select>
191
+ {:else if editing}
192
+ <input
193
+ class="bo-edit"
194
+ value={seed ?? String(value ?? '')}
119
195
  use:focusSelect
120
196
  onkeydown={onEditKey}
121
197
  onblur={onEditBlur}
@@ -128,13 +204,13 @@
128
204
  {:else if col.type === 'sparkline'}
129
205
  <Sparkline candles={candlesOf(row, col.sparkKey)} />
130
206
  {:else if col.type === 'text'}
131
- <strong>{formatCell(col, value)}</strong>{#if col.sub}<em>{row[col.sub]}</em>{/if}
207
+ <strong>{formatCell(col, value, row)}</strong>{#if col.sub}<em>{row[col.sub]}</em>{/if}
132
208
  {:else if col.flash}
133
209
  {#key row.flashSeq}
134
- <span class="flash {row.flashDir}">{formatCell(col, value)}</span>
210
+ <span class="flash {row.flashDir}">{formatCell(col, value, row)}</span>
135
211
  {/key}
136
212
  {:else}
137
- {formatCell(col, value)}
213
+ {formatCell(col, value, row)}
138
214
  {/if}
139
215
  </span>
140
216
 
@@ -171,6 +247,44 @@
171
247
  .spark {
172
248
  overflow: visible;
173
249
  }
250
+ /* Tree-data gutter: indent + expand chevron, before the cell content. */
251
+ .tree-gutter {
252
+ display: inline-flex;
253
+ align-items: center;
254
+ flex: none;
255
+ }
256
+ .tree-toggle {
257
+ width: 18px;
258
+ height: 18px;
259
+ padding: 0;
260
+ font-size: 10px;
261
+ line-height: 1;
262
+ color: var(--bo-text-dim);
263
+ background: transparent;
264
+ border: 0;
265
+ border-radius: 4px;
266
+ cursor: pointer;
267
+ }
268
+ .tree-toggle:hover {
269
+ color: var(--bo-text);
270
+ background: var(--bo-row-hover);
271
+ }
272
+ .tree-leaf {
273
+ display: inline-block;
274
+ width: 18px;
275
+ }
276
+ .drag-handle {
277
+ flex: none;
278
+ margin-right: 4px;
279
+ font-size: 12px;
280
+ line-height: 1;
281
+ color: var(--bo-text-dim);
282
+ cursor: grab;
283
+ user-select: none;
284
+ }
285
+ .drag-handle:active {
286
+ cursor: grabbing;
287
+ }
174
288
  .bo-edit {
175
289
  width: 100%;
176
290
  height: 100%;
@@ -8,11 +8,15 @@ type $$ComponentProps = {
8
8
  selected?: boolean;
9
9
  focused?: boolean;
10
10
  pinned?: boolean;
11
- pinLeft?: number;
11
+ pinSide?: 'left' | 'right';
12
+ pinOffset?: number;
12
13
  /** Fixed pixel width (pinned/horizontal-scroll mode). */
13
14
  width?: number;
14
15
  alt?: boolean;
15
16
  editing?: boolean;
17
+ /** Type-to-edit seed: when set, the editor opens pre-filled with this string
18
+ (the character that triggered the edit) instead of the current value. */
19
+ seed?: string | null;
16
20
  colIndex?: number;
17
21
  cellId?: string;
18
22
  cellSnippet?: Snippet<[{
@@ -20,6 +24,18 @@ type $$ComponentProps = {
20
24
  column: ColumnDef;
21
25
  value: unknown;
22
26
  }]>;
27
+ /** Tree-data gutter for the first column: indent + expand chevron. */
28
+ tree?: {
29
+ depth: number;
30
+ hasChildren: boolean;
31
+ expanded: boolean;
32
+ onToggle: () => void;
33
+ };
34
+ /** Drag-to-reorder handle for the first column (HTML5 draggable grip). */
35
+ dragHandle?: {
36
+ onStart: () => void;
37
+ onEnd: () => void;
38
+ };
23
39
  onCellDown?: (r: number, c: number, e: PointerEvent) => void;
24
40
  onCellEnter?: (r: number, c: number, e: PointerEvent) => void;
25
41
  onCellClick?: (r: number, c: number, e: MouseEvent) => void;