bo-grid 0.1.0 → 0.7.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 +154 -28
- package/dist/grid/Cell.svelte +173 -11
- package/dist/grid/Cell.svelte.d.ts +22 -1
- package/dist/grid/FilterMenu.svelte +263 -0
- package/dist/grid/FilterMenu.svelte.d.ts +15 -0
- package/dist/grid/Grid.svelte +978 -61
- package/dist/grid/Grid.svelte.d.ts +72 -1
- package/dist/grid/Pager.svelte +59 -0
- package/dist/grid/Pager.svelte.d.ts +9 -0
- package/dist/grid/RowMenu.svelte +66 -0
- package/dist/grid/RowMenu.svelte.d.ts +12 -0
- package/dist/grid/ToolPanel.svelte +117 -0
- package/dist/grid/ToolPanel.svelte.d.ts +15 -0
- package/dist/grid/column.d.ts +33 -5
- package/dist/grid/column.js +8 -6
- package/dist/grid/export.js +1 -1
- package/dist/grid/filtering.d.ts +41 -0
- package/dist/grid/filtering.js +107 -0
- package/dist/grid/grouping.d.ts +2 -0
- package/dist/grid/pin.d.ts +10 -6
- package/dist/grid/pin.js +38 -15
- package/dist/grid/sizing.d.ts +2 -2
- package/dist/grid/sizing.js +4 -3
- package/dist/grid/source.d.ts +3 -0
- package/dist/grid/source.js +5 -0
- package/dist/grid/source.svelte.d.ts +2 -1
- package/dist/grid/source.svelte.js +6 -5
- package/dist/grid/tree.d.ts +12 -0
- package/dist/grid/tree.js +21 -0
- package/dist/index.d.ts +1 -0
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -1,26 +1,31 @@
|
|
|
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
|
|
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)** ·
|
|
10
|
+
**[Roadmap](./ROADMAP.md)**
|
|
9
11
|
|
|
10
|
-
The demo is a small gallery of
|
|
12
|
+
The demo is a small gallery of nine grid types — a realtime **Trading desk**, a
|
|
11
13
|
grouped **Portfolio** with subtotals and pivot, a general-purpose editable
|
|
12
14
|
**Spreadsheet**, a live **Order book** depth ladder, a **Correlation** heatmap
|
|
13
|
-
matrix, a **Leaderboard** with rank medals and score bars,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
>
|
|
18
|
-
>
|
|
15
|
+
matrix, a **Leaderboard** with rank medals and score bars, a **Tree** file
|
|
16
|
+
explorer, a drag-to-reorder **Tasks** list, and a **1M-row** trade tape windowed
|
|
17
|
+
from a synthetic source — switch between them with the tabs.
|
|
18
|
+
|
|
19
|
+
> **Status: v0.2.** Working: config-driven columns, virtual scroll, client
|
|
20
|
+
> single/multi/controlled sort + global & per-column filters, multi-cell selection
|
|
21
|
+
> + live range aggregation, row grouping (nested, collapsible, sticky headers,
|
|
22
|
+
> live subtotals), pivot tables, tree data, master-detail, a server-side
|
|
19
23
|
> `RowSource` for huge datasets, CSV/Excel export, drag-to-reorder and
|
|
20
|
-
> drag-to-resize columns, pinned columns,
|
|
21
|
-
> copy/paste, sparklines,
|
|
22
|
-
>
|
|
23
|
-
>
|
|
24
|
+
> drag-to-resize columns, pinned columns (left/right), row selection, pagination,
|
|
25
|
+
> inline editing with validation, type-to-edit, clipboard copy/paste, sparklines,
|
|
26
|
+
> realtime flash, heatmaps, theming, and full keyboard a11y. **SSR/SvelteKit-safe.**
|
|
27
|
+
> Unit tests (Vitest), type-check, a headless mount smoke-test, an SSR render
|
|
28
|
+
> check, and library + demo bundle-size budgets all run in CI. A formal WCAG audit
|
|
24
29
|
> is the main thing left — see the roadmap.
|
|
25
30
|
|
|
26
31
|
## Why
|
|
@@ -30,9 +35,14 @@ tape windowed from a synthetic source — switch between them with the tabs.
|
|
|
30
35
|
| Price | $$$ / dev / year | Free (MIT) |
|
|
31
36
|
| Sparklines | paid tier | built in |
|
|
32
37
|
| Realtime cell updates | DIY / complex | built-in primitive |
|
|
33
|
-
| Bundle |
|
|
38
|
+
| Bundle | hundreds of KB | **~20 KB gzip** ([benchmarks](./BENCHMARKS.md)) |
|
|
34
39
|
| Svelte | wrapper | native Svelte 5 |
|
|
35
40
|
|
|
41
|
+
bo-grid ships most of AG Grid's **paid (Enterprise)** features — grouping, pivot,
|
|
42
|
+
tree data, master-detail, range selection, Excel export, sparklines — for free.
|
|
43
|
+
See the honest **[bo-grid vs AG Grid](./docs/vs-ag-grid.md)** comparison (including
|
|
44
|
+
what it doesn't do) to decide.
|
|
45
|
+
|
|
36
46
|
## Install
|
|
37
47
|
|
|
38
48
|
```sh
|
|
@@ -40,6 +50,12 @@ npm i bo-grid
|
|
|
40
50
|
# peer dependency: svelte@^5
|
|
41
51
|
```
|
|
42
52
|
|
|
53
|
+
Works with **SvelteKit / SSR** out of the box — `<Grid>` server-renders to HTML
|
|
54
|
+
without touching `window`/`document`/`localStorage` (a CI gate, `pnpm ssr`,
|
|
55
|
+
proves it). The package is `sideEffects: false`, so unused exports tree-shake
|
|
56
|
+
away. See the **[SvelteKit guide](./docs/sveltekit.md)** for `load`-function data,
|
|
57
|
+
realtime feeds, and layout persistence.
|
|
58
|
+
|
|
43
59
|
## Usage
|
|
44
60
|
|
|
45
61
|
```svelte
|
|
@@ -112,6 +128,15 @@ a function for variable per-row heights (in-memory mode):
|
|
|
112
128
|
Variable heights use a prefix-sum + binary-search virtualizer, so scrolling stays
|
|
113
129
|
O(log n). Source mode is uniform-only (unloaded row heights aren't known).
|
|
114
130
|
|
|
131
|
+
## Pagination
|
|
132
|
+
|
|
133
|
+
Prefer pages over one long scroll? Set `pageSize` (> 0) for a paged view with a
|
|
134
|
+
first/prev/next/last pager; rows still virtualize within each page. In-memory mode.
|
|
135
|
+
|
|
136
|
+
```svelte
|
|
137
|
+
<Grid {rows} {columns} height={640} pageSize={25} />
|
|
138
|
+
```
|
|
139
|
+
|
|
115
140
|
## Sort & filter
|
|
116
141
|
|
|
117
142
|
Click a column header to sort (asc → desc → off). **Shift-click** additional
|
|
@@ -121,9 +146,27 @@ to opt out. Sorting is a snapshot — rows hold position while their values upda
|
|
|
121
146
|
in place (trading-grid behaviour), so a realtime feed never reshuffles the view.
|
|
122
147
|
|
|
123
148
|
Pass a `filter` string to quick-filter rows (matches across column values). Drive
|
|
124
|
-
it from your own search input —
|
|
125
|
-
to add a row of **per-column filter inputs** under
|
|
126
|
-
every non-empty column filter; in-memory mode).
|
|
149
|
+
it from your own search input — or set `quickFilter` for a **built-in search box**
|
|
150
|
+
above the grid. Set `filterRow` to add a row of **per-column filter inputs** under
|
|
151
|
+
the header (rows must match every non-empty column filter; in-memory mode).
|
|
152
|
+
|
|
153
|
+
For richer filtering, set `filterMenu` to add a **funnel to each column header**.
|
|
154
|
+
Clicking it opens a menu whose control matches the column type — text
|
|
155
|
+
(contains / equals / starts / ends), number (`=, ≠, <, ≤, >, ≥, between`), or date
|
|
156
|
+
(before / after / on / between). Set `col.filter: 'set'` for a **set filter** — a
|
|
157
|
+
searchable checkbox list of the column's distinct values (All / None). The menu
|
|
158
|
+
is lazy-loaded on first open, so it costs nothing until used; disable it per
|
|
159
|
+
column with `col.filter: false`:
|
|
160
|
+
|
|
161
|
+
```svelte
|
|
162
|
+
<Grid {rows} {columns} height={640} filterMenu />
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Filtering is uncontrolled by default. To **own the filter state** (persist it, set
|
|
166
|
+
initial filters, sync to the URL), pass a controlled `columnFilters` map and
|
|
167
|
+
handle `onFilterChange` — mirrors controlled `sort`. In **server (`source`) mode**
|
|
168
|
+
the filter menu still works: text/number/date filters are delegated to your
|
|
169
|
+
`RowSource` via `params.columnFilters` (set filters need in-memory data).
|
|
127
170
|
|
|
128
171
|
Sorting is uncontrolled by default. To own it (persist it, set an initial sort,
|
|
129
172
|
or sync to the URL), pass a controlled `sort` array and handle `onSortChange`:
|
|
@@ -147,6 +190,13 @@ values flow through the same validation as inline editing — non-editable colum
|
|
|
147
190
|
and invalid numbers are skipped — and each accepted cell emits `onCellEdit`, so
|
|
148
191
|
paste only does anything when you've wired that callback.
|
|
149
192
|
|
|
193
|
+
Set `fillHandle` for an **Excel-style fill handle**: the selection grows a small
|
|
194
|
+
square at its bottom-right corner; drag it down or right to copy the selected
|
|
195
|
+
value(s) across the extended range (editable columns; multi-cell selections tile).
|
|
196
|
+
|
|
197
|
+
Edits, paste and fill are **undoable** with <kbd>Ctrl/⌘</kbd>+<kbd>Z</kbd> (redo
|
|
198
|
+
with <kbd>Ctrl/⌘</kbd>+<kbd>Y</kbd>). A paste or fill undoes as a single step.
|
|
199
|
+
|
|
150
200
|
When more than one cell is selected, a footer bar shows live **Sum / Avg / Count /
|
|
151
201
|
Min / Max** over the numeric cells in the range — and it keeps updating as a
|
|
152
202
|
realtime feed ticks. Choose which stats to show:
|
|
@@ -175,7 +225,8 @@ columns (and `rowClass`) but are display-only:
|
|
|
175
225
|
|
|
176
226
|
Set `rowSelection` for a leading checkbox column — whole-row selection keyed by
|
|
177
227
|
`row.id`, so it survives sorting and filtering (unlike the positional cell
|
|
178
|
-
selection above). The header checkbox selects/clears all matching rows
|
|
228
|
+
selection above). The header checkbox selects/clears all matching rows, and
|
|
229
|
+
<kbd>Space</kbd> toggles the focused row from the keyboard.
|
|
179
230
|
`onRowSelectionChange` reports the selected ids:
|
|
180
231
|
|
|
181
232
|
```svelte
|
|
@@ -269,8 +320,10 @@ client-only, so it's not applied in source mode.
|
|
|
269
320
|
|
|
270
321
|
## Column reorder
|
|
271
322
|
|
|
272
|
-
|
|
273
|
-
|
|
323
|
+
Pass `onRowReorder(from, to)` to enable **drag-to-reorder rows** via a handle in
|
|
324
|
+
the first column — reorder your own `rows` array in the callback (flat, unsorted,
|
|
325
|
+
in-memory lists). Drag any column header to reorder columns; pass `persistKey` to
|
|
326
|
+
remember the user's order across reloads (saved to `localStorage`):
|
|
274
327
|
|
|
275
328
|
```svelte
|
|
276
329
|
<Grid {rows} {columns} persistKey="watchlist" height={640} />
|
|
@@ -283,8 +336,9 @@ grip to reset it to its default width. Resizing a fit-to-width (`flex`) column
|
|
|
283
336
|
pins it to the dragged width and lets its neighbours absorb the difference. The
|
|
284
337
|
same `persistKey` remembers widths across reloads.
|
|
285
338
|
|
|
286
|
-
|
|
287
|
-
|
|
339
|
+
Bound a column's draggable range with `minWidth` / `maxWidth`. Resizing is on by
|
|
340
|
+
default. Turn it off for the whole grid with `resizable={false}`, or per column
|
|
341
|
+
with `resizable: false` (handy for a fixed action column):
|
|
288
342
|
|
|
289
343
|
```svelte
|
|
290
344
|
<Grid {rows} {columns} resizable={false} height={640} />
|
|
@@ -299,6 +353,19 @@ your own column-picker UI and drive the prop; the grid stays presentation-only:
|
|
|
299
353
|
<Grid {rows} {columns} hiddenColumns={['bonus', 'rating']} height={640} />
|
|
300
354
|
```
|
|
301
355
|
|
|
356
|
+
Or set `columnMenu` for a **per-column header menu** (a ⋮ trigger, or
|
|
357
|
+
<kbd>Alt</kbd>+<kbd>↓</kbd> on the focused column) with **sort**, **pin**
|
|
358
|
+
(left/right/unpin), **Autosize** (fit to content), and **Hide column** actions,
|
|
359
|
+
and `columnsPanel` for a **"Columns" button** that opens a checklist to toggle
|
|
360
|
+
visibility (and restore hidden columns). Runtime hide/pin compose with
|
|
361
|
+
`hiddenColumns` / `col.pinned`, persist via `persistKey`, and hide reports through
|
|
362
|
+
`onColumnVisibilityChange`:
|
|
363
|
+
|
|
364
|
+
```svelte
|
|
365
|
+
<Grid {rows} {columns} columnMenu height={640}
|
|
366
|
+
onColumnVisibilityChange={(hidden) => (myHidden = hidden)} />
|
|
367
|
+
```
|
|
368
|
+
|
|
302
369
|
## Header groups
|
|
303
370
|
|
|
304
371
|
Give columns a `group` label to render a spanning parent header over consecutive
|
|
@@ -312,6 +379,33 @@ const columns = [
|
|
|
312
379
|
];
|
|
313
380
|
```
|
|
314
381
|
|
|
382
|
+
## Tree data
|
|
383
|
+
|
|
384
|
+
Pass `getChildren` to render hierarchical rows — `rows` become the roots, and each
|
|
385
|
+
node gets an indented first column with an expand chevron when it has children:
|
|
386
|
+
|
|
387
|
+
```svelte
|
|
388
|
+
<Grid {rows} {columns} height={520} getChildren={(r) => r.children} />
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
In tree mode the grid renders the tree directly (filter/sort/group/paginate are
|
|
392
|
+
not applied to it). Nodes are keyboard-accessible: **→** expands a collapsed node,
|
|
393
|
+
**←** collapses an expanded one, and rows expose `aria-level` / `aria-expanded`.
|
|
394
|
+
|
|
395
|
+
## Master-detail
|
|
396
|
+
|
|
397
|
+
Pass a `detail` snippet to render an expandable panel under each row — the grid
|
|
398
|
+
adds a leading expand toggle and virtualizes the expanded heights (`detailHeight`,
|
|
399
|
+
default 160). In-memory mode:
|
|
400
|
+
|
|
401
|
+
```svelte
|
|
402
|
+
<Grid {rows} {columns} height={640} detailHeight={120} detail={rowDetail} />
|
|
403
|
+
|
|
404
|
+
{#snippet rowDetail({ row })}
|
|
405
|
+
<div class="detail">…anything about {row.name}…</div>
|
|
406
|
+
{/snippet}
|
|
407
|
+
```
|
|
408
|
+
|
|
315
409
|
## Per-row styling
|
|
316
410
|
|
|
317
411
|
Return a class from `rowClass` to style rows by their data (e.g. red/green book
|
|
@@ -326,15 +420,37 @@ levels). Rows live inside the grid, so target the class with `:global(...)`:
|
|
|
326
420
|
</style>
|
|
327
421
|
```
|
|
328
422
|
|
|
423
|
+
For per-column styling, a column's `cellClass` (static or `(value, row)`
|
|
424
|
+
conditional) and `headerClass` add classes to that column's cells/header:
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
{ type: 'number', key: 'pnl', header: 'P&L',
|
|
428
|
+
cellClass: (v) => (Number(v) < 0 ? 'loss' : 'gain'), headerClass: 'num-head' }
|
|
429
|
+
```
|
|
430
|
+
|
|
329
431
|
`onRowClick(row, event)` fires when a row is activated by click or <kbd>Enter</kbd>
|
|
330
432
|
on the focused cell — wire it to open a detail view or navigate.
|
|
331
433
|
|
|
434
|
+
Pass `rowMenu(row)` to add a **right-click menu** of row actions; each item runs
|
|
435
|
+
its `onSelect` and the menu closes (also on outside-click or <kbd>Esc</kbd>). It
|
|
436
|
+
is keyboard-accessible: the <kbd>ContextMenu</kbd> key (or
|
|
437
|
+
<kbd>Shift</kbd>+<kbd>F10</kbd>) opens it at the focused cell.
|
|
438
|
+
|
|
439
|
+
```svelte
|
|
440
|
+
<Grid {rows} {columns} height={640}
|
|
441
|
+
rowMenu={(r) => [{ label: 'Delete', onSelect: () => remove(r.id) }]} />
|
|
442
|
+
```
|
|
443
|
+
|
|
332
444
|
## Inline editing
|
|
333
445
|
|
|
334
446
|
Mark a column `editable: true`. Double-click a cell (or press <kbd>Enter</kbd> on
|
|
335
447
|
the focused cell) to edit; <kbd>Enter</kbd>/blur commits, <kbd>Esc</kbd> cancels.
|
|
336
|
-
|
|
337
|
-
|
|
448
|
+
Or just start typing — a printable key on a focused editable cell opens the
|
|
449
|
+
editor seeded with that character (Excel-style type-to-edit). The editor matches
|
|
450
|
+
the column type: numeric columns get a number input, `date` columns a native date
|
|
451
|
+
picker, `options` columns a `<select>`, everything else a text input. The grid is
|
|
452
|
+
controlled, so it reports the change via `onCellEdit` — update your own row data
|
|
453
|
+
there:
|
|
338
454
|
|
|
339
455
|
```svelte
|
|
340
456
|
<Grid
|
|
@@ -354,17 +470,27 @@ applies to paste too):
|
|
|
354
470
|
{ type: 'number', key: 'qty', header: 'Qty', editable: true, validate: (v) => v >= 0 }
|
|
355
471
|
```
|
|
356
472
|
|
|
473
|
+
Give an editable column `options` to edit it via a dropdown instead of a text
|
|
474
|
+
input (enum/status columns):
|
|
475
|
+
|
|
476
|
+
```ts
|
|
477
|
+
{ type: 'text', key: 'status', header: 'Status', editable: true,
|
|
478
|
+
options: ['New', 'Active', 'Closed'] }
|
|
479
|
+
```
|
|
480
|
+
|
|
357
481
|
## Pinned columns
|
|
358
482
|
|
|
359
|
-
Set `pinned: true` on a column to keep it visible while the rest
|
|
360
|
-
horizontally
|
|
361
|
-
|
|
483
|
+
Set `pinned: true` (or `'left'`) on a column to keep it visible while the rest
|
|
484
|
+
scroll horizontally; `pinned: 'right'` sticks it to the right edge (e.g. an
|
|
485
|
+
actions or total column). Opt-in: with no pinned columns the grid stays
|
|
486
|
+
fit-to-width (no horizontal scroll).
|
|
362
487
|
|
|
363
488
|
```ts
|
|
364
489
|
const columns = [
|
|
365
490
|
{ type: 'text', key: 'symbol', header: 'Symbol', width: 132, pinned: true },
|
|
366
491
|
{ type: 'price', key: 'price', header: 'Price', width: 88, pinned: true },
|
|
367
|
-
// …wider columns scroll under the pinned ones
|
|
492
|
+
// …wider columns scroll under the pinned ones…
|
|
493
|
+
{ type: 'number', key: 'pnl', header: 'P&L', width: 96, pinned: 'right' },
|
|
368
494
|
];
|
|
369
495
|
```
|
|
370
496
|
|
package/dist/grid/Cell.svelte
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { Snippet } from 'svelte';
|
|
3
3
|
import type { ColumnDef, GridRow } from './column';
|
|
4
|
-
import { formatCell, colStyle, candlesOf } from './column';
|
|
4
|
+
import { formatCell, colStyle, candlesOf, isNumeric } from './column';
|
|
5
5
|
import { heatColor } from './heatmap';
|
|
6
6
|
import Sparkline from '../sparkline/Sparkline.svelte';
|
|
7
7
|
|
|
@@ -13,19 +13,26 @@
|
|
|
13
13
|
selected = false,
|
|
14
14
|
focused = false,
|
|
15
15
|
pinned = false,
|
|
16
|
-
|
|
16
|
+
pinSide = 'left',
|
|
17
|
+
pinOffset = 0,
|
|
17
18
|
width,
|
|
18
19
|
alt = false,
|
|
19
20
|
editing = false,
|
|
21
|
+
seed = null,
|
|
22
|
+
fillCorner = false,
|
|
23
|
+
fillpreview = false,
|
|
20
24
|
colIndex,
|
|
21
25
|
cellId,
|
|
22
26
|
cellSnippet,
|
|
27
|
+
tree,
|
|
28
|
+
dragHandle,
|
|
23
29
|
onCellDown,
|
|
24
30
|
onCellEnter,
|
|
25
31
|
onCellClick,
|
|
26
32
|
onCellDblClick,
|
|
27
33
|
onEditCommit,
|
|
28
34
|
onEditCancel,
|
|
35
|
+
onFillStart,
|
|
29
36
|
}: {
|
|
30
37
|
col: ColumnDef;
|
|
31
38
|
row: GridRow;
|
|
@@ -34,26 +41,47 @@
|
|
|
34
41
|
selected?: boolean;
|
|
35
42
|
focused?: boolean;
|
|
36
43
|
pinned?: boolean;
|
|
37
|
-
|
|
44
|
+
pinSide?: 'left' | 'right';
|
|
45
|
+
pinOffset?: number;
|
|
38
46
|
/** Fixed pixel width (pinned/horizontal-scroll mode). */
|
|
39
47
|
width?: number;
|
|
40
48
|
alt?: boolean;
|
|
41
49
|
editing?: boolean;
|
|
50
|
+
/** Type-to-edit seed: when set, the editor opens pre-filled with this string
|
|
51
|
+
(the character that triggered the edit) instead of the current value. */
|
|
52
|
+
seed?: string | null;
|
|
53
|
+
/** Show the fill handle (this cell is the selection's bottom-right corner). */
|
|
54
|
+
fillCorner?: boolean;
|
|
55
|
+
/** This cell is inside the in-progress fill drag's preview range. */
|
|
56
|
+
fillpreview?: boolean;
|
|
42
57
|
colIndex?: number;
|
|
43
58
|
cellId?: string;
|
|
44
59
|
cellSnippet?: Snippet<[{ row: GridRow; column: ColumnDef; value: unknown }]>;
|
|
60
|
+
/** Tree-data gutter for the first column: indent + expand chevron. */
|
|
61
|
+
tree?: { depth: number; hasChildren: boolean; expanded: boolean; onToggle: () => void };
|
|
62
|
+
/** Drag-to-reorder handle for the first column (HTML5 draggable grip). */
|
|
63
|
+
dragHandle?: { onStart: () => void; onEnd: () => void };
|
|
45
64
|
onCellDown?: (r: number, c: number, e: PointerEvent) => void;
|
|
46
65
|
onCellEnter?: (r: number, c: number, e: PointerEvent) => void;
|
|
47
66
|
onCellClick?: (r: number, c: number, e: MouseEvent) => void;
|
|
48
67
|
onCellDblClick?: (r: number, c: number) => void;
|
|
49
68
|
onEditCommit?: (raw: string) => void;
|
|
50
69
|
onEditCancel?: () => void;
|
|
70
|
+
onFillStart?: () => void;
|
|
51
71
|
} = $props();
|
|
52
72
|
|
|
53
73
|
let cancelled = false;
|
|
54
74
|
function focusSelect(node: HTMLInputElement) {
|
|
55
75
|
node.focus();
|
|
56
|
-
|
|
76
|
+
// Only text/search inputs support text selection; number/date inputs throw.
|
|
77
|
+
if (node.type !== 'text' && node.type !== 'search') return;
|
|
78
|
+
// Type-to-edit: keep the seeded character and put the caret after it;
|
|
79
|
+
// otherwise select the whole value so the next keystroke replaces it.
|
|
80
|
+
if (seed != null) node.setSelectionRange(node.value.length, node.value.length);
|
|
81
|
+
else node.select();
|
|
82
|
+
}
|
|
83
|
+
function focusEl(node: HTMLElement) {
|
|
84
|
+
node.focus();
|
|
57
85
|
}
|
|
58
86
|
function onEditKey(e: KeyboardEvent) {
|
|
59
87
|
e.stopPropagation(); // keep arrows/Enter in the input, not the grid
|
|
@@ -77,13 +105,31 @@
|
|
|
77
105
|
// goes through the $state getter — fine-grained reactivity is preserved even
|
|
78
106
|
// though the key is only known at runtime.
|
|
79
107
|
const value = $derived(row[col.key]);
|
|
108
|
+
// Typed inline editor: date columns edit with a date picker, numeric columns
|
|
109
|
+
// with a numeric input; everything else stays a text input.
|
|
110
|
+
const editorType = $derived(col.type === 'date' ? 'date' : isNumeric(col) ? 'number' : 'text');
|
|
111
|
+
const editorValue = $derived(
|
|
112
|
+
col.type === 'date' && Number.isFinite(Number(value))
|
|
113
|
+
? new Date(Number(value)).toISOString().slice(0, 10)
|
|
114
|
+
: String(value ?? ''),
|
|
115
|
+
);
|
|
80
116
|
const kind = $derived(col.type === 'text' ? 'text' : col.type === 'sparkline' ? 'spark' : 'num');
|
|
117
|
+
// Optional per-column cell class (static string or value/row function).
|
|
118
|
+
const extraClass = $derived(
|
|
119
|
+
typeof col.cellClass === 'function' ? (col.cellClass(value, row) ?? '') : (col.cellClass ?? ''),
|
|
120
|
+
);
|
|
121
|
+
// Native tooltip of the full value (opt-in via column `tooltip`).
|
|
122
|
+
const tip = $derived(
|
|
123
|
+
col.tooltip && col.type !== 'sparkline' && col.type !== 'custom'
|
|
124
|
+
? formatCell(col, value, row)
|
|
125
|
+
: undefined,
|
|
126
|
+
);
|
|
81
127
|
|
|
82
128
|
function cellStyle(): string {
|
|
83
129
|
let s = width != null ? `flex:0 0 ${width}px;width:${width}px;` : colStyle(col);
|
|
84
130
|
if (col.type === 'heatmap') s += `background:${heatColor(Number(value), col.min, col.max)};`;
|
|
85
131
|
if (pinned) {
|
|
86
|
-
s += `position:sticky
|
|
132
|
+
s += `position:sticky;${pinSide}:${pinOffset}px;z-index:1;`;
|
|
87
133
|
// Pinned cells must be opaque to cover scrolled content. Heatmap already
|
|
88
134
|
// set a background; otherwise match the (alternating) row colour.
|
|
89
135
|
if (col.type !== 'heatmap') s += `background:var(${alt ? '--bo-row-a' : '--bo-row-b'});`;
|
|
@@ -95,16 +141,18 @@
|
|
|
95
141
|
<!-- Keyboard interaction is handled at the grid level (arrow nav via aria-activedescendant + Enter); this cell click is a pointer affordance. -->
|
|
96
142
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
97
143
|
<span
|
|
98
|
-
class="c {kind}"
|
|
144
|
+
class="c {kind} {extraClass}"
|
|
99
145
|
class:dim={col.type === 'volume'}
|
|
100
146
|
class:pos={col.type === 'percent' && Number(value) >= 0}
|
|
101
147
|
class:neg={col.type === 'percent' && Number(value) < 0}
|
|
102
148
|
class:sel={selected}
|
|
103
149
|
class:focus={focused}
|
|
150
|
+
class:fillpreview={fillpreview}
|
|
104
151
|
style={cellStyle()}
|
|
105
152
|
role="gridcell"
|
|
106
153
|
tabindex="-1"
|
|
107
154
|
id={cellId}
|
|
155
|
+
title={tip}
|
|
108
156
|
aria-colindex={colIndex}
|
|
109
157
|
aria-selected={selected}
|
|
110
158
|
onpointerdown={(e) => onCellDown?.(r, c, e)}
|
|
@@ -112,10 +160,58 @@
|
|
|
112
160
|
onclick={(e) => onCellClick?.(r, c, e)}
|
|
113
161
|
ondblclick={() => onCellDblClick?.(r, c)}
|
|
114
162
|
>
|
|
115
|
-
{#if
|
|
116
|
-
<
|
|
163
|
+
{#if dragHandle}
|
|
164
|
+
<span
|
|
165
|
+
class="drag-handle"
|
|
166
|
+
role="button"
|
|
167
|
+
tabindex="-1"
|
|
168
|
+
aria-label="Drag to reorder row"
|
|
169
|
+
draggable="true"
|
|
170
|
+
onpointerdown={(e) => e.stopPropagation()}
|
|
171
|
+
ondragstart={() => dragHandle.onStart()}
|
|
172
|
+
ondragend={() => dragHandle.onEnd()}
|
|
173
|
+
>⠿</span>
|
|
174
|
+
{/if}
|
|
175
|
+
{#if tree}
|
|
176
|
+
<span class="tree-gutter" style="padding-left:{tree.depth * 16}px">
|
|
177
|
+
{#if tree.hasChildren}
|
|
178
|
+
<button
|
|
179
|
+
class="tree-toggle"
|
|
180
|
+
type="button"
|
|
181
|
+
aria-expanded={tree.expanded}
|
|
182
|
+
aria-label="Toggle children"
|
|
183
|
+
onpointerdown={(e) => e.stopPropagation()}
|
|
184
|
+
onclick={(e) => {
|
|
185
|
+
e.stopPropagation();
|
|
186
|
+
tree.onToggle();
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{tree.expanded ? '▾' : '▸'}
|
|
190
|
+
</button>
|
|
191
|
+
{:else}
|
|
192
|
+
<span class="tree-leaf"></span>
|
|
193
|
+
{/if}
|
|
194
|
+
</span>
|
|
195
|
+
{/if}
|
|
196
|
+
{#if editing && col.options && col.options.length > 0}
|
|
197
|
+
<select
|
|
117
198
|
class="bo-edit"
|
|
118
199
|
value={String(value ?? '')}
|
|
200
|
+
use:focusEl
|
|
201
|
+
onkeydown={onEditKey}
|
|
202
|
+
onblur={onEditBlur}
|
|
203
|
+
onpointerdown={(e) => e.stopPropagation()}
|
|
204
|
+
onclick={(e) => e.stopPropagation()}
|
|
205
|
+
>
|
|
206
|
+
{#each col.options as opt (opt)}
|
|
207
|
+
<option value={opt}>{opt}</option>
|
|
208
|
+
{/each}
|
|
209
|
+
</select>
|
|
210
|
+
{:else if editing}
|
|
211
|
+
<input
|
|
212
|
+
class="bo-edit"
|
|
213
|
+
type={editorType}
|
|
214
|
+
value={seed ?? editorValue}
|
|
119
215
|
use:focusSelect
|
|
120
216
|
onkeydown={onEditKey}
|
|
121
217
|
onblur={onEditBlur}
|
|
@@ -128,13 +224,25 @@
|
|
|
128
224
|
{:else if col.type === 'sparkline'}
|
|
129
225
|
<Sparkline candles={candlesOf(row, col.sparkKey)} />
|
|
130
226
|
{:else if col.type === 'text'}
|
|
131
|
-
<strong>{formatCell(col, value)}</strong>{#if col.sub}<em>{row[col.sub]}</em>{/if}
|
|
227
|
+
<strong>{formatCell(col, value, row)}</strong>{#if col.sub}<em>{row[col.sub]}</em>{/if}
|
|
132
228
|
{:else if col.flash}
|
|
133
229
|
{#key row.flashSeq}
|
|
134
|
-
<span class="flash {row.flashDir}">{formatCell(col, value)}</span>
|
|
230
|
+
<span class="flash {row.flashDir}">{formatCell(col, value, row)}</span>
|
|
135
231
|
{/key}
|
|
136
232
|
{:else}
|
|
137
|
-
{formatCell(col, value)}
|
|
233
|
+
{formatCell(col, value, row)}
|
|
234
|
+
{/if}
|
|
235
|
+
{#if fillCorner}
|
|
236
|
+
<span
|
|
237
|
+
class="fill-handle"
|
|
238
|
+
role="button"
|
|
239
|
+
tabindex="-1"
|
|
240
|
+
aria-label="Fill"
|
|
241
|
+
onpointerdown={(e) => {
|
|
242
|
+
e.stopPropagation();
|
|
243
|
+
onFillStart?.();
|
|
244
|
+
}}
|
|
245
|
+
></span>
|
|
138
246
|
{/if}
|
|
139
247
|
</span>
|
|
140
248
|
|
|
@@ -171,6 +279,44 @@
|
|
|
171
279
|
.spark {
|
|
172
280
|
overflow: visible;
|
|
173
281
|
}
|
|
282
|
+
/* Tree-data gutter: indent + expand chevron, before the cell content. */
|
|
283
|
+
.tree-gutter {
|
|
284
|
+
display: inline-flex;
|
|
285
|
+
align-items: center;
|
|
286
|
+
flex: none;
|
|
287
|
+
}
|
|
288
|
+
.tree-toggle {
|
|
289
|
+
width: 18px;
|
|
290
|
+
height: 18px;
|
|
291
|
+
padding: 0;
|
|
292
|
+
font-size: 10px;
|
|
293
|
+
line-height: 1;
|
|
294
|
+
color: var(--bo-text-dim);
|
|
295
|
+
background: transparent;
|
|
296
|
+
border: 0;
|
|
297
|
+
border-radius: 4px;
|
|
298
|
+
cursor: pointer;
|
|
299
|
+
}
|
|
300
|
+
.tree-toggle:hover {
|
|
301
|
+
color: var(--bo-text);
|
|
302
|
+
background: var(--bo-row-hover);
|
|
303
|
+
}
|
|
304
|
+
.tree-leaf {
|
|
305
|
+
display: inline-block;
|
|
306
|
+
width: 18px;
|
|
307
|
+
}
|
|
308
|
+
.drag-handle {
|
|
309
|
+
flex: none;
|
|
310
|
+
margin-right: 4px;
|
|
311
|
+
font-size: 12px;
|
|
312
|
+
line-height: 1;
|
|
313
|
+
color: var(--bo-text-dim);
|
|
314
|
+
cursor: grab;
|
|
315
|
+
user-select: none;
|
|
316
|
+
}
|
|
317
|
+
.drag-handle:active {
|
|
318
|
+
cursor: grabbing;
|
|
319
|
+
}
|
|
174
320
|
.bo-edit {
|
|
175
321
|
width: 100%;
|
|
176
322
|
height: 100%;
|
|
@@ -204,6 +350,22 @@
|
|
|
204
350
|
inset 0 0 0 1000px var(--bo-sel-fill),
|
|
205
351
|
inset 0 0 0 1px var(--bo-sel-border);
|
|
206
352
|
}
|
|
353
|
+
/* Fill: a draggable square at the selection's corner + the drag preview. */
|
|
354
|
+
.fill-handle {
|
|
355
|
+
position: absolute;
|
|
356
|
+
right: -3px;
|
|
357
|
+
bottom: -3px;
|
|
358
|
+
width: 7px;
|
|
359
|
+
height: 7px;
|
|
360
|
+
background: var(--bo-sel-border);
|
|
361
|
+
border: 1px solid var(--bo-bg);
|
|
362
|
+
cursor: crosshair;
|
|
363
|
+
z-index: 4;
|
|
364
|
+
touch-action: none;
|
|
365
|
+
}
|
|
366
|
+
.c.fillpreview {
|
|
367
|
+
box-shadow: inset 0 0 0 1px var(--bo-sel-border);
|
|
368
|
+
}
|
|
207
369
|
|
|
208
370
|
.flash {
|
|
209
371
|
animation: flash 0.3s linear;
|
|
@@ -8,11 +8,19 @@ type $$ComponentProps = {
|
|
|
8
8
|
selected?: boolean;
|
|
9
9
|
focused?: boolean;
|
|
10
10
|
pinned?: boolean;
|
|
11
|
-
|
|
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;
|
|
20
|
+
/** Show the fill handle (this cell is the selection's bottom-right corner). */
|
|
21
|
+
fillCorner?: boolean;
|
|
22
|
+
/** This cell is inside the in-progress fill drag's preview range. */
|
|
23
|
+
fillpreview?: boolean;
|
|
16
24
|
colIndex?: number;
|
|
17
25
|
cellId?: string;
|
|
18
26
|
cellSnippet?: Snippet<[{
|
|
@@ -20,12 +28,25 @@ type $$ComponentProps = {
|
|
|
20
28
|
column: ColumnDef;
|
|
21
29
|
value: unknown;
|
|
22
30
|
}]>;
|
|
31
|
+
/** Tree-data gutter for the first column: indent + expand chevron. */
|
|
32
|
+
tree?: {
|
|
33
|
+
depth: number;
|
|
34
|
+
hasChildren: boolean;
|
|
35
|
+
expanded: boolean;
|
|
36
|
+
onToggle: () => void;
|
|
37
|
+
};
|
|
38
|
+
/** Drag-to-reorder handle for the first column (HTML5 draggable grip). */
|
|
39
|
+
dragHandle?: {
|
|
40
|
+
onStart: () => void;
|
|
41
|
+
onEnd: () => void;
|
|
42
|
+
};
|
|
23
43
|
onCellDown?: (r: number, c: number, e: PointerEvent) => void;
|
|
24
44
|
onCellEnter?: (r: number, c: number, e: PointerEvent) => void;
|
|
25
45
|
onCellClick?: (r: number, c: number, e: MouseEvent) => void;
|
|
26
46
|
onCellDblClick?: (r: number, c: number) => void;
|
|
27
47
|
onEditCommit?: (raw: string) => void;
|
|
28
48
|
onEditCancel?: () => void;
|
|
49
|
+
onFillStart?: () => void;
|
|
29
50
|
};
|
|
30
51
|
declare const Cell: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
31
52
|
type Cell = ReturnType<typeof Cell>;
|