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 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 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)** ·
10
+ **[Roadmap](./ROADMAP.md)**
9
11
 
10
- The demo is a small gallery of seven grid types — a realtime **Trading desk**, a
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, 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
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, 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
+ > 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 | ~500 KB | a few KB gzip |
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 — 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).
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
- Drag any column header to reorder columns. Pass `persistKey` to remember the
273
- user's order across reloads (saved to `localStorage`):
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
- 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):
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
- The grid is controlled, so it reports the change via `onCellEdit` update your
337
- own row data there:
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 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.
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
 
@@ -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
- pinLeft = 0,
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
- pinLeft?: number;
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
- node.select();
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;left:${pinLeft}px;z-index:1;`;
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 editing}
116
- <input
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
- 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;
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>;