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
@@ -0,0 +1,1339 @@
1
+ <script module lang="ts">
2
+ // Per-instance id counter, for stable ARIA ids (active-descendant).
3
+ let uid = 0;
4
+ </script>
5
+
6
+ <script lang="ts">
7
+ import { untrack } from 'svelte';
8
+ import type { Snippet } from 'svelte';
9
+ import type { ColumnDef, GridRow, SortState, SortDir, CellEditEvent } from './column';
10
+ import { colStyle, isNumeric, isSortable, isEditable, compareBySorts, formatCell } from './column';
11
+ import { arrangePinned } from './pin';
12
+ import { uniformHeights, variableHeights } from './rowheight';
13
+ import { themeVars, lightTheme, type GridTheme } from './theme';
14
+ import { Selection } from './selection.svelte';
15
+ import { aggregate, type AggKind, type AggResult } from './aggregate';
16
+ import { buildFlatRows, activeGroupsAt, type VisualRow, type GroupNode } from './grouping';
17
+ import { moveIndex } from './reorder';
18
+ import { parseClipboard, isSingleCell } from './clipboard';
19
+ import { applyWidths, clampWidth, isResizable, type WidthMap } from './sizing';
20
+ import type { RowSource } from './source';
21
+ import { RowSourceController } from './source.svelte';
22
+ import Cell from './Cell.svelte';
23
+ import GroupRow from './GroupRow.svelte';
24
+ import AggregationBar from './AggregationBar.svelte';
25
+
26
+ let {
27
+ rows,
28
+ columns,
29
+ height,
30
+ filter = '',
31
+ groupBy = [],
32
+ aggregations = ['sum', 'avg', 'count', 'min', 'max'],
33
+ persistKey,
34
+ source,
35
+ onCellEdit,
36
+ rowHeight,
37
+ theme,
38
+ resizable = true,
39
+ rowSelection = false,
40
+ onRowSelectionChange,
41
+ hiddenColumns = [],
42
+ rowClass,
43
+ getRowId = (r: GridRow) => r.id,
44
+ onRowClick,
45
+ sort,
46
+ onSortChange,
47
+ footer = false,
48
+ onCellClick,
49
+ pinnedRows = [],
50
+ filterRow = false,
51
+ cell,
52
+ }: {
53
+ rows: GridRow[];
54
+ columns: ColumnDef[];
55
+ height: number;
56
+ /** Row height in px (uniform), or a function for variable heights
57
+ (in-memory mode only). Default 36. */
58
+ rowHeight?: number | ((row: GridRow, index: number) => number);
59
+ /** Built-in theme name or a custom token map. Default 'dark'. */
60
+ theme?: 'dark' | 'light' | GridTheme;
61
+ /** Allow drag-to-resize column widths. Default true; opt out per column
62
+ with `resizable: false`. */
63
+ resizable?: boolean;
64
+ /** Show a leading checkbox column for whole-row selection (keyed by row id,
65
+ stable across sort/filter). Default false. */
66
+ rowSelection?: boolean;
67
+ /** Called with the selected row ids whenever the row-selection set changes. */
68
+ onRowSelectionChange?: (selectedIds: Array<string | number>) => void;
69
+ /** Column keys to hide (controlled). Build your own column-picker UI and
70
+ drive this prop — the grid stays presentation-only. */
71
+ hiddenColumns?: string[];
72
+ /** Return extra CSS class(es) for a data row (e.g. to colour by value).
73
+ Style them via `:global(.your-class)` since rows live inside the grid. */
74
+ rowClass?: (row: GridRow) => string | undefined;
75
+ /** Identity key for row selection. Defaults to `row.id`; override for
76
+ string/UUID/composite keys. */
77
+ getRowId?: (row: GridRow) => string | number;
78
+ /** Called when a data row is activated by click or Enter (open a detail
79
+ view, navigate, …). Edit-input and checkbox clicks are excluded. */
80
+ onRowClick?: (row: GridRow, event: MouseEvent | KeyboardEvent) => void;
81
+ /** Controlled sort order (multi-key, primary first). When set, the grid
82
+ reflects this and reports changes via `onSortChange` instead of holding
83
+ its own state. Omit for uncontrolled sorting. */
84
+ sort?: SortState[];
85
+ /** Called with the new sort order whenever a header is clicked. */
86
+ onSortChange?: (sort: SortState[]) => void;
87
+ /** Show a pinned totals row: each column with a `groupAgg` shows that
88
+ aggregate over all (filtered) rows. In-memory mode only. Default false. */
89
+ footer?: boolean;
90
+ /** Called when a cell is clicked, with its row, column and value. Fires in
91
+ addition to `onRowClick`; excluded for the edit input. */
92
+ onCellClick?: (
93
+ info: { row: GridRow; column: ColumnDef; value: unknown },
94
+ event: MouseEvent,
95
+ ) => void;
96
+ /** Rows pinned to the top, always visible above the scroll (a benchmark, a
97
+ summary, "your position"). Display-only — not virtualized or selectable. */
98
+ pinnedRows?: GridRow[];
99
+ /** Show a per-column filter input row under the header. Rows must match every
100
+ non-empty column filter (AND). In-memory mode only. Default false. */
101
+ filterRow?: boolean;
102
+ filter?: string;
103
+ groupBy?: string[];
104
+ aggregations?: AggKind[];
105
+ persistKey?: string;
106
+ /** Back the grid with a windowed/server data source instead of `rows`.
107
+ In source mode, sort + filter are delegated to the source; grouping is
108
+ not applied. */
109
+ source?: RowSource;
110
+ /** Called when an editable cell is committed. Update your row data in here. */
111
+ onCellEdit?: (e: CellEditEvent) => void;
112
+ /** Render content for `type: 'custom'` columns. */
113
+ cell?: Snippet<[{ row: GridRow; column: ColumnDef; value: unknown }]>;
114
+ } = $props();
115
+
116
+ const ROW_H = 36;
117
+ const OVERSCAN = 6;
118
+ const gid = `bo-grid-${uid++}`;
119
+
120
+ let scrollTop = $state(0);
121
+ // Sort order, primary first. Empty = unsorted. Multiple keys via Shift-click.
122
+ // Controlled by the `sort` prop when provided, else internal state.
123
+ let internalSorts = $state<SortState[]>([]);
124
+ const sorts = $derived(sort ?? internalSorts);
125
+ function setSorts(next: SortState[]): void {
126
+ if (sort === undefined) internalSorts = next; // uncontrolled: own the state
127
+ onSortChange?.(next); // always notify (lets consumers observe/persist)
128
+ }
129
+ let gridEl: HTMLDivElement;
130
+ let viewportEl: HTMLDivElement;
131
+ let headEl: HTMLDivElement;
132
+ let filterRowEl = $state<HTMLDivElement>();
133
+ let groupHeadEl = $state<HTMLDivElement>();
134
+
135
+ const sel = new Selection();
136
+ let dragging = $state(false);
137
+ let editing = $state<{ r: number; c: number } | null>(null);
138
+
139
+ function startEdit(r: number, c: number) {
140
+ if (!isEditable(cols[c]) || !dataAt(r)) return;
141
+ editing = { r, c };
142
+ }
143
+ // Coerce + validate a raw string for cell (r,c) and emit onCellEdit.
144
+ // Returns true if a value was written, false if rejected (not editable,
145
+ // missing row, or invalid number). Shared by inline edit and paste.
146
+ function writeCell(r: number, c: number, raw: string): boolean {
147
+ const col = cols[c];
148
+ if (!col || !isEditable(col)) return false;
149
+ const row = dataAt(r);
150
+ if (!row) return false;
151
+ let value: string | number = raw;
152
+ if (isNumeric(col)) {
153
+ const n = Number(raw);
154
+ if (!Number.isFinite(n)) return false; // reject invalid number, keep old value
155
+ value = n;
156
+ }
157
+ if (col.validate && !col.validate(value, row)) return false; // consumer rejected it
158
+ onCellEdit?.({ row, column: col, value });
159
+ return true;
160
+ }
161
+
162
+ function commitEdit(r: number, c: number, raw: string) {
163
+ editing = null;
164
+ writeCell(r, c, raw);
165
+ }
166
+
167
+ const collapsed = new Set<string>();
168
+ let collapsedVersion = $state(0);
169
+
170
+ // Column order (indices into `columns`); reordered by header drag-and-drop.
171
+ let order = $state<number[]>([]);
172
+ let dragSrc = $state(-1);
173
+ let dragOver = $state(-1);
174
+
175
+ // User column-width overrides (drag-to-resize), keyed by column key.
176
+ let widths = $state<WidthMap>({});
177
+
178
+ // Per-column filter text (filterRow), keyed by column key.
179
+ let colFilters = $state<Record<string, string>>({});
180
+
181
+ // Whole-row selection (opt-in), keyed by row id so it survives sort/filter.
182
+ // Plain Set + a version counter for reactivity (same pattern as `collapsed`).
183
+ const SEL_W = 40; // checkbox column width (px)
184
+ const selectedRows = new Set<string | number>();
185
+ let selRowsVersion = $state(0);
186
+ const selOffset = $derived(rowSelection ? 1 : 0);
187
+
188
+ function isRowSelected(id: string | number): boolean {
189
+ selRowsVersion; // track
190
+ return selectedRows.has(id);
191
+ }
192
+ function toggleRow(id: string | number): void {
193
+ if (selectedRows.has(id)) selectedRows.delete(id);
194
+ else selectedRows.add(id);
195
+ selRowsVersion++;
196
+ onRowSelectionChange?.([...selectedRows]);
197
+ }
198
+
199
+ const ordered = $derived(order.length === columns.length ? order.map((i) => columns[i]) : columns);
200
+ // Drop hidden columns (controlled via `hiddenColumns`). Applied after ordering
201
+ // so `order` stays indexed over the full column set.
202
+ const visible = $derived(
203
+ hiddenColumns.length ? ordered.filter((c) => !hiddenColumns.includes(c.key)) : ordered,
204
+ );
205
+ // Apply any resize overrides (turns the dragged column fixed-width), then
206
+ // pin-arrange. Both are no-ops by default, so the grid stays fit-to-width.
207
+ const sized = $derived(applyWidths(visible, widths));
208
+ // Pin-arrangement: pinned columns move to the front and get sticky offsets.
209
+ // When nothing is pinned this is a no-op and the grid stays fit-to-width.
210
+ const layout = $derived(arrangePinned(sized));
211
+ const cols = $derived(layout.columns);
212
+ const pinned = $derived(layout.anyPinned);
213
+
214
+ // Spanning header groups: consecutive columns sharing a `group` label merge
215
+ // into one parent header cell (width = sum of child widths). null when unused.
216
+ const headerGroups = $derived.by<{ label: string; width: number }[] | null>(() => {
217
+ if (!cols.some((c) => c.group)) return null;
218
+ const runs: { label: string; width: number }[] = [];
219
+ for (let i = 0; i < cols.length; i++) {
220
+ const label = cols[i].group ?? '';
221
+ const w = layout.info[i].width;
222
+ const last = runs[runs.length - 1];
223
+ if (last && label !== '' && last.label === label) last.width += w;
224
+ else runs.push({ label, width: w });
225
+ }
226
+ return runs;
227
+ });
228
+
229
+ // Theme → inline `--bo-grid-*` overrides. 'dark' uses the built-in defaults.
230
+ const themeStyle = $derived(
231
+ !theme || theme === 'dark' ? '' : themeVars(theme === 'light' ? lightTheme : theme),
232
+ );
233
+
234
+ // Screen readers track the active cell via aria-activedescendant (the focus
235
+ // cell is always scrolled into view, so its element exists in the DOM).
236
+ const activeId = $derived(sel.focus ? `${gid}-r${sel.focus.r}-c${sel.focus.c}` : undefined);
237
+
238
+ // Pinned columns sit to the right of the (also-sticky) checkbox column, so
239
+ // their sticky-left offsets shift by SEL_W when row selection is on.
240
+ function headStyle(ci: number): string {
241
+ if (!pinned) return colStyle(cols[ci]);
242
+ const inf = layout.info[ci];
243
+ let s = `flex:0 0 ${inf.width}px;width:${inf.width}px;`;
244
+ if (inf.pinned) s += `position:sticky;left:${inf.left + selOffset * SEL_W}px;z-index:5;background:var(--bo-header-bg);`;
245
+ return s;
246
+ }
247
+ function cellWidthStyle(ci: number): string {
248
+ if (!pinned) return colStyle(cols[ci]);
249
+ const inf = layout.info[ci];
250
+ let s = `flex:0 0 ${inf.width}px;width:${inf.width}px;`;
251
+ if (inf.pinned) s += `position:sticky;left:${inf.left + selOffset * SEL_W}px;z-index:1;background:var(--bo-bg);`;
252
+ return s;
253
+ }
254
+ // The leading checkbox column: a fixed-width flex item, sticky-left when the
255
+ // grid scrolls horizontally (pinned mode).
256
+ function selCellStyle(header: boolean): string {
257
+ let s = `flex:0 0 ${SEL_W}px;width:${SEL_W}px;`;
258
+ if (pinned)
259
+ s += `position:sticky;left:0;z-index:${header ? 6 : 2};background:var(--bo-${header ? 'header-bg' : 'bg'});`;
260
+ return s;
261
+ }
262
+
263
+ // Windowed data source controller (only in source mode).
264
+ const controller = $derived(source ? new RowSourceController(source) : null);
265
+
266
+ function orderStorageKey(): string | null {
267
+ return persistKey ? `bo-grid:order:${persistKey}` : null;
268
+ }
269
+
270
+ $effect(() => {
271
+ columns;
272
+ untrack(() => {
273
+ const base = columns.map((_, i) => i);
274
+ const key = orderStorageKey();
275
+ if (key && typeof localStorage !== 'undefined') {
276
+ try {
277
+ const saved = JSON.parse(localStorage.getItem(key) ?? 'null');
278
+ if (Array.isArray(saved) && saved.length === base.length && base.every((i) => saved.includes(i))) {
279
+ order = saved;
280
+ return;
281
+ }
282
+ } catch {
283
+ /* corrupt value — fall through to default */
284
+ }
285
+ }
286
+ order = base;
287
+ });
288
+ });
289
+
290
+ function moveColumn(from: number, to: number) {
291
+ const next = moveIndex(order, from, to);
292
+ if (next === order) return;
293
+ order = next;
294
+ sel.clear();
295
+ editing = null;
296
+ const key = orderStorageKey();
297
+ if (key && typeof localStorage !== 'undefined') {
298
+ try {
299
+ localStorage.setItem(key, JSON.stringify(next));
300
+ } catch {
301
+ /* storage unavailable — order still applies this session */
302
+ }
303
+ }
304
+ }
305
+
306
+ // ---- Column resizing -------------------------------------------------------
307
+ function widthStorageKey(): string | null {
308
+ return persistKey ? `bo-grid:widths:${persistKey}` : null;
309
+ }
310
+ function persistWidths() {
311
+ const key = widthStorageKey();
312
+ if (!key || typeof localStorage === 'undefined') return;
313
+ try {
314
+ localStorage.setItem(key, JSON.stringify(widths));
315
+ } catch {
316
+ /* storage unavailable — overrides still apply this session */
317
+ }
318
+ }
319
+
320
+ $effect(() => {
321
+ persistKey;
322
+ untrack(() => {
323
+ const key = widthStorageKey();
324
+ if (!key || typeof localStorage === 'undefined') return;
325
+ try {
326
+ const saved = JSON.parse(localStorage.getItem(key) ?? 'null');
327
+ if (saved && typeof saved === 'object') widths = saved as WidthMap;
328
+ } catch {
329
+ /* corrupt value — ignore */
330
+ }
331
+ });
332
+ });
333
+
334
+ let resize: { key: string; startX: number; startW: number } | null = null;
335
+ let justResized = false;
336
+
337
+ function startResize(ci: number, e: PointerEvent) {
338
+ if (!isResizable(cols[ci], resizable)) return;
339
+ e.preventDefault();
340
+ e.stopPropagation();
341
+ const headCell = (e.currentTarget as HTMLElement).closest('.h') as HTMLElement | null;
342
+ const startW = headCell ? headCell.getBoundingClientRect().width : layout.info[ci].width;
343
+ resize = { key: cols[ci].key, startX: e.clientX, startW };
344
+ window.addEventListener('pointermove', onResizeMove);
345
+ window.addEventListener('pointerup', onResizeUp);
346
+ }
347
+ function onResizeMove(e: PointerEvent) {
348
+ if (!resize) return;
349
+ const w = clampWidth(resize.startW + (e.clientX - resize.startX));
350
+ widths = { ...widths, [resize.key]: w };
351
+ }
352
+ function onResizeUp() {
353
+ if (!resize) return;
354
+ resize = null;
355
+ justResized = true; // swallow the click that ends this drag (no sort toggle)
356
+ window.removeEventListener('pointermove', onResizeMove);
357
+ window.removeEventListener('pointerup', onResizeUp);
358
+ persistWidths();
359
+ }
360
+ /** Double-click a resize grip to clear the override and restore the default. */
361
+ function resetWidth(ci: number, e: MouseEvent) {
362
+ e.preventDefault();
363
+ e.stopPropagation();
364
+ const key = cols[ci].key;
365
+ if (widths[key] == null) return;
366
+ const next = { ...widths };
367
+ delete next[key];
368
+ widths = next;
369
+ persistWidths();
370
+ }
371
+
372
+ // Cycle one key: undefined → asc → desc → removed.
373
+ function nextDir(dir: SortDir | undefined): SortDir | null {
374
+ if (dir === undefined) return 'asc';
375
+ if (dir === 'asc') return 'desc';
376
+ return null; // was desc → drop the key
377
+ }
378
+
379
+ function toggleSort(col: ColumnDef, additive: boolean) {
380
+ if (justResized) {
381
+ justResized = false;
382
+ return;
383
+ }
384
+ if (!isSortable(col)) return;
385
+ const current = sorts.find((s) => s.key === col.key);
386
+ const dir = nextDir(current?.dir);
387
+
388
+ if (additive) {
389
+ // Shift-click: add/cycle this key while keeping the rest of the order.
390
+ const rest = sorts.filter((s) => s.key !== col.key);
391
+ setSorts(dir ? [...rest, { key: col.key, dir }] : rest);
392
+ return;
393
+ }
394
+ // Plain click: sort by this column alone. If it's already the sole key,
395
+ // cycle its direction (asc → desc → off); otherwise start fresh ascending.
396
+ const soleAndSame = sorts.length === 1 && sorts[0].key === col.key;
397
+ if (soleAndSame) setSorts(dir ? [{ key: col.key, dir }] : []);
398
+ else setSorts([{ key: col.key, dir: 'asc' }]);
399
+ }
400
+
401
+ // Sort direction + 1-based position for a column, or null if unsorted.
402
+ function sortInfo(key: string): { dir: SortDir; pos: number } | null {
403
+ const i = sorts.findIndex((s) => s.key === key);
404
+ return i === -1 ? null : { dir: sorts[i].dir, pos: i + 1 };
405
+ }
406
+
407
+ // In-memory pipeline (skipped entirely in source mode).
408
+ const view = $derived.by(() => {
409
+ if (source) return [] as GridRow[];
410
+ const base = rows;
411
+ const allCols = columns;
412
+ const f = filter.trim().toLowerCase();
413
+ const s = sorts;
414
+ // Active per-column filters (filterRow): [key, lowercased needle] pairs.
415
+ const colF = Object.entries(colFilters)
416
+ .map(([k, v]) => [k, v.trim().toLowerCase()] as const)
417
+ .filter(([, v]) => v.length > 0);
418
+ return untrack(() => {
419
+ let r = base;
420
+ if (f) r = r.filter((row) => allCols.some((c) => String(row[c.key] ?? '').toLowerCase().includes(f)));
421
+ if (colF.length > 0) {
422
+ r = r.filter((row) => colF.every(([k, v]) => String(row[k] ?? '').toLowerCase().includes(v)));
423
+ }
424
+ if (s.length > 0) r = [...r].sort((a, b) => compareBySorts(a, b, s));
425
+ return r;
426
+ });
427
+ });
428
+
429
+ const flat = $derived.by<VisualRow[]>(() => {
430
+ const v = view;
431
+ const gb = groupBy;
432
+ collapsedVersion;
433
+ return untrack(() => buildFlatRows(v, gb, collapsed));
434
+ });
435
+
436
+ // Header select-all state over the in-memory rows (source mode can't enumerate
437
+ // unloaded ids, so the header checkbox is disabled there).
438
+ const selectAll = $derived.by(() => {
439
+ selRowsVersion;
440
+ if (source) return { checked: false, indeterminate: false };
441
+ const v = view;
442
+ let n = 0;
443
+ for (const r of v) if (selectedRows.has(getRowId(r))) n++;
444
+ return { checked: n > 0 && n === v.length, indeterminate: n > 0 && n < v.length };
445
+ });
446
+
447
+ function toggleAll(): void {
448
+ if (source) return;
449
+ const clearing = selectAll.checked;
450
+ for (const r of view) {
451
+ if (clearing) selectedRows.delete(getRowId(r));
452
+ else selectedRows.add(getRowId(r));
453
+ }
454
+ selRowsVersion++;
455
+ onRowSelectionChange?.([...selectedRows]);
456
+ }
457
+
458
+ // Pinned totals row: per-column `groupAgg` over all (filtered) rows. Reads row
459
+ // values reactively so it stays live with the feed. In-memory mode only.
460
+ const footerCells = $derived.by<string[] | null>(() => {
461
+ if (!footer || source) return null;
462
+ const v = view;
463
+ return cols.map((col) => {
464
+ if (col.type === 'sparkline' || col.type === 'text' || col.type === 'custom' || !col.groupAgg) return '';
465
+ const vals: number[] = [];
466
+ for (const row of v) {
467
+ const n = Number(row[col.key]);
468
+ if (Number.isFinite(n)) vals.push(n);
469
+ }
470
+ const a = aggregate(vals);
471
+ if (!a) return '';
472
+ return col.groupAgg === 'count' ? String(a.count) : formatCell(col, a[col.groupAgg]);
473
+ });
474
+ });
475
+
476
+ // Unified row count: from the source total or the flattened in-memory list.
477
+ const rowCount = $derived(source ? (controller?.total ?? 0) : flat.length);
478
+
479
+ const maxR = $derived(rowCount - 1);
480
+ const maxC = $derived(cols.length - 1);
481
+
482
+ // Row-height model. Uniform (O(1)) by default; a function rowHeight switches to
483
+ // a prefix-sum model (in-memory only — source mode can't know unloaded heights).
484
+ const baseH = $derived(typeof rowHeight === 'number' && rowHeight > 0 ? rowHeight : ROW_H);
485
+ const variable = $derived(typeof rowHeight === 'function' && !source);
486
+ const heights = $derived.by<number[] | null>(() => {
487
+ if (!variable) return null;
488
+ const fn = rowHeight as (row: GridRow, index: number) => number;
489
+ const arr = new Array<number>(flat.length);
490
+ let di = 0;
491
+ for (let i = 0; i < flat.length; i++) {
492
+ const it = flat[i];
493
+ arr[i] = it.kind === 'data' ? Math.max(1, fn(it.row, di++)) : baseH;
494
+ }
495
+ return arr;
496
+ });
497
+ const hm = $derived(variable && heights ? variableHeights(heights) : uniformHeights(rowCount, baseH));
498
+
499
+ const total = $derived(hm.total);
500
+ const rowWidthStyle = $derived(
501
+ pinned ? `width:${layout.totalWidth + selOffset * SEL_W}px;right:auto;` : '',
502
+ );
503
+ const visibleCount = $derived(Math.ceil(height / baseH) + OVERSCAN * 2);
504
+ const start = $derived(Math.max(0, hm.indexAt(scrollTop) - OVERSCAN));
505
+ const renderEnd = $derived(
506
+ source
507
+ ? (controller && controller.total > 0 ? Math.min(start + visibleCount, controller.total) : start + visibleCount)
508
+ : Math.min(flat.length, hm.indexAt(scrollTop + height) + OVERSCAN + 1),
509
+ );
510
+
511
+ type RenderItem =
512
+ | { vr: number; kind: 'group'; group: GroupNode }
513
+ | { vr: number; kind: 'data'; row: GridRow }
514
+ | { vr: number; kind: 'skeleton' };
515
+
516
+ const renderItems = $derived.by<RenderItem[]>(() => {
517
+ const out: RenderItem[] = [];
518
+ if (source && controller) {
519
+ controller.version; // track cache updates
520
+ for (let vr = start; vr < renderEnd; vr++) {
521
+ const row = controller.rowAt(vr);
522
+ out.push(row ? { vr, kind: 'data', row } : { vr, kind: 'skeleton' });
523
+ }
524
+ } else {
525
+ for (let vr = start; vr < renderEnd; vr++) {
526
+ const item = flat[vr];
527
+ if (!item) continue;
528
+ if (item.kind === 'group') out.push({ vr, kind: 'group', group: item.group });
529
+ else out.push({ vr, kind: 'data', row: item.row });
530
+ }
531
+ }
532
+ return out;
533
+ });
534
+
535
+ const stickyGroups = $derived(
536
+ !source && groupBy.length > 0 ? activeGroupsAt(flat, hm.indexAt(scrollTop)) : [],
537
+ );
538
+
539
+ // Fetch the visible window whenever it, the sort, or the filter changes.
540
+ $effect(() => {
541
+ const ctrl = controller;
542
+ if (!ctrl) return;
543
+ const range = { start, end: start + visibleCount };
544
+ const s = sorts;
545
+ const f = filter;
546
+ void ctrl.fetch(range, s, f);
547
+ });
548
+
549
+ function dataAt(r: number): GridRow | null {
550
+ if (source && controller) return controller.rowAt(r);
551
+ const item = flat[r];
552
+ return item && item.kind === 'data' ? item.row : null;
553
+ }
554
+
555
+ const agg = $derived.by<AggResult | null>(() => {
556
+ const b = sel.bounds;
557
+ if (!b || sel.count <= 1) return null;
558
+ const vals: number[] = [];
559
+ const rEnd = Math.min(b.r1, rowCount - 1);
560
+ const cEnd = Math.min(b.c1, cols.length - 1);
561
+ for (let r = b.r0; r <= rEnd; r++) {
562
+ const row = dataAt(r);
563
+ if (!row) continue;
564
+ for (let c = b.c0; c <= cEnd; c++) {
565
+ if (!isNumeric(cols[c])) continue;
566
+ const v = Number(row[cols[c].key]);
567
+ if (Number.isFinite(v)) vals.push(v);
568
+ }
569
+ }
570
+ return aggregate(vals);
571
+ });
572
+
573
+ function onScroll(e: Event) {
574
+ const el = e.currentTarget as HTMLElement;
575
+ scrollTop = el.scrollTop;
576
+ if (pinned && headEl) headEl.scrollLeft = el.scrollLeft; // keep header in sync
577
+ if (pinned && filterRowEl) filterRowEl.scrollLeft = el.scrollLeft; // and the filter row
578
+ if (pinned && groupHeadEl) groupHeadEl.scrollLeft = el.scrollLeft; // and group headers
579
+ }
580
+
581
+ function toggleGroup(path: string) {
582
+ if (collapsed.has(path)) collapsed.delete(path);
583
+ else collapsed.add(path);
584
+ collapsedVersion++;
585
+ sel.clear();
586
+ }
587
+
588
+ function onCellDown(r: number, c: number, e: PointerEvent) {
589
+ if (e.button !== 0) return;
590
+ e.preventDefault();
591
+ gridEl?.focus();
592
+ if (e.shiftKey) sel.extendTo(r, c);
593
+ else sel.start(r, c);
594
+ dragging = true;
595
+ }
596
+
597
+ function onCellEnter(r: number, c: number) {
598
+ if (dragging) sel.extendTo(r, c);
599
+ }
600
+
601
+ function onCellClicked(r: number, c: number, e: MouseEvent) {
602
+ if (!onCellClick) return;
603
+ const row = dataAt(r);
604
+ if (!row) return;
605
+ const column = cols[c];
606
+ onCellClick({ row, column, value: row[column.key] }, e);
607
+ }
608
+
609
+ // Move the focus to an absolute (r, c), clamped; extend the selection if asked.
610
+ function focusTo(r: number, c: number, extend: boolean) {
611
+ const rr = Math.max(0, Math.min(r, maxR));
612
+ const cc = Math.max(0, Math.min(c, maxC));
613
+ if (extend) sel.extendTo(rr, cc);
614
+ else sel.start(rr, cc);
615
+ scrollFocusIntoView();
616
+ }
617
+
618
+ function scrollFocusIntoView() {
619
+ const f = sel.focus;
620
+ if (!f || !viewportEl) return;
621
+ const top = hm.offsetOf(f.r);
622
+ const h = hm.heightOf(f.r);
623
+ if (top < viewportEl.scrollTop) viewportEl.scrollTop = top;
624
+ else if (top + h > viewportEl.scrollTop + height) viewportEl.scrollTop = top + h - height;
625
+ }
626
+
627
+ async function copySelection() {
628
+ const b = sel.bounds;
629
+ if (!b) return;
630
+ const lines: string[] = [];
631
+ const rEnd = Math.min(b.r1, rowCount - 1);
632
+ const cEnd = Math.min(b.c1, cols.length - 1);
633
+ for (let r = b.r0; r <= rEnd; r++) {
634
+ const row = dataAt(r);
635
+ if (!row) continue;
636
+ const cells: string[] = [];
637
+ for (let c = b.c0; c <= cEnd; c++) {
638
+ const col = cols[c];
639
+ cells.push(col.type === 'sparkline' || col.type === 'custom' ? '' : formatCell(col, row[col.key]));
640
+ }
641
+ lines.push(cells.join('\t'));
642
+ }
643
+ try {
644
+ await navigator.clipboard?.writeText(lines.join('\n'));
645
+ } catch {
646
+ /* clipboard unavailable — ignore */
647
+ }
648
+ }
649
+
650
+ async function pasteSelection() {
651
+ if (!onCellEdit) return; // no sink for edits — paste is a no-op
652
+ const anchor = sel.bounds;
653
+ if (!anchor) return;
654
+ let text = '';
655
+ try {
656
+ text = (await navigator.clipboard?.readText()) ?? '';
657
+ } catch {
658
+ return; // clipboard read blocked/unavailable
659
+ }
660
+ const grid = parseClipboard(text);
661
+ if (grid.length === 0) return;
662
+
663
+ const single = isSingleCell(grid);
664
+ // Single value fills the whole selection (Excel behaviour); a block
665
+ // pastes from the top-left anchor, clamped to the grid bounds.
666
+ const r0 = anchor.r0;
667
+ const c0 = anchor.c0;
668
+ const rSpan = single ? anchor.r1 - anchor.r0 + 1 : grid.length;
669
+ let wrote = 0;
670
+ for (let dr = 0; dr < rSpan; dr++) {
671
+ const r = r0 + dr;
672
+ if (r > rowCount - 1) break;
673
+ const srcRow = single ? grid[0] : grid[dr];
674
+ const cSpan = single ? anchor.c1 - anchor.c0 + 1 : srcRow.length;
675
+ for (let dc = 0; dc < cSpan; dc++) {
676
+ const c = c0 + dc;
677
+ if (c > cols.length - 1) break;
678
+ const raw = single ? grid[0][0] : (srcRow[dc] ?? '');
679
+ if (writeCell(r, c, raw)) wrote++;
680
+ }
681
+ }
682
+ // Surface the pasted region as the new selection so it's visible.
683
+ if (wrote > 0) {
684
+ const rEnd = Math.min(r0 + rSpan - 1, rowCount - 1);
685
+ const cEnd = single
686
+ ? anchor.c1
687
+ : Math.min(c0 + Math.max(...grid.map((g) => g.length)) - 1, cols.length - 1);
688
+ sel.start(r0, c0);
689
+ sel.extendTo(rEnd, cEnd);
690
+ }
691
+ }
692
+
693
+ function onKeydown(e: KeyboardEvent) {
694
+ const mod = e.ctrlKey || e.metaKey;
695
+ if (e.key === 'Enter' && sel.focus && !editing) {
696
+ const f = sel.focus;
697
+ if (isEditable(cols[f.c])) {
698
+ e.preventDefault();
699
+ startEdit(f.r, f.c);
700
+ return;
701
+ }
702
+ if (onRowClick) {
703
+ const row = dataAt(f.r);
704
+ if (row) {
705
+ e.preventDefault();
706
+ onRowClick(row, e);
707
+ return;
708
+ }
709
+ }
710
+ }
711
+ if (mod && e.key.toLowerCase() === 'a') {
712
+ e.preventDefault();
713
+ sel.selectAll(rowCount, cols.length);
714
+ return;
715
+ }
716
+ if (mod && e.key.toLowerCase() === 'c') {
717
+ e.preventDefault();
718
+ void copySelection();
719
+ return;
720
+ }
721
+ if (mod && e.key.toLowerCase() === 'v') {
722
+ e.preventDefault();
723
+ void pasteSelection();
724
+ return;
725
+ }
726
+ if (e.key === 'Escape') {
727
+ sel.clear();
728
+ return;
729
+ }
730
+ // Home/End (row, or whole grid with Ctrl/⌘); PageUp/PageDown by a viewport page.
731
+ const f = sel.focus;
732
+ if (f && (e.key === 'Home' || e.key === 'End' || e.key === 'PageUp' || e.key === 'PageDown')) {
733
+ e.preventDefault();
734
+ const page = Math.max(1, Math.floor(height / baseH) - 1);
735
+ if (e.key === 'Home') focusTo(mod ? 0 : f.r, 0, e.shiftKey);
736
+ else if (e.key === 'End') focusTo(mod ? maxR : f.r, maxC, e.shiftKey);
737
+ else if (e.key === 'PageDown') focusTo(f.r + page, f.c, e.shiftKey);
738
+ else focusTo(f.r - page, f.c, e.shiftKey);
739
+ return;
740
+ }
741
+ const delta: Record<string, [number, number]> = {
742
+ ArrowUp: [-1, 0],
743
+ ArrowDown: [1, 0],
744
+ ArrowLeft: [0, -1],
745
+ ArrowRight: [0, 1],
746
+ };
747
+ const d = delta[e.key];
748
+ if (d) {
749
+ e.preventDefault();
750
+ sel.move(d[0], d[1], e.shiftKey, maxR, maxC);
751
+ scrollFocusIntoView();
752
+ }
753
+ }
754
+
755
+ $effect(() => {
756
+ const up = () => (dragging = false);
757
+ window.addEventListener('pointerup', up);
758
+ return () => window.removeEventListener('pointerup', up);
759
+ });
760
+
761
+ // Positional selection: clear it whenever the row order/contents shift.
762
+ $effect(() => {
763
+ filter;
764
+ sorts;
765
+ collapsedVersion;
766
+ source;
767
+ untrack(() => {
768
+ sel.clear();
769
+ editing = null;
770
+ });
771
+ });
772
+ </script>
773
+
774
+ <!-- `bo-grid` is an unscoped public class: a stable hook for consumer overrides. -->
775
+ <div
776
+ class="bo-grid grid"
777
+ role="grid"
778
+ tabindex="0"
779
+ id={gid}
780
+ aria-rowcount={rowCount + 1}
781
+ aria-colcount={cols.length + selOffset}
782
+ aria-multiselectable="true"
783
+ aria-activedescendant={activeId}
784
+ style={themeStyle}
785
+ bind:this={gridEl}
786
+ onkeydown={onKeydown}
787
+ >
788
+ {#if headerGroups}
789
+ <div class="head-groups" aria-hidden="true" bind:this={groupHeadEl} style={pinned ? 'overflow:hidden;' : ''}>
790
+ {#if rowSelection}<span class="selcell" style={selCellStyle(true)}></span>{/if}
791
+ {#each headerGroups as g, gi (gi)}
792
+ <span class="hg" class:empty={!g.label} style="flex:0 0 {g.width}px;width:{g.width}px;">{g.label}</span>
793
+ {/each}
794
+ </div>
795
+ {/if}
796
+ <div class="head" role="row" aria-rowindex={1} bind:this={headEl} style={pinned ? 'overflow:hidden;' : ''}>
797
+ {#if rowSelection}
798
+ <span class="selcell selhead" role="columnheader" aria-colindex={1} style={selCellStyle(true)}>
799
+ <input
800
+ type="checkbox"
801
+ class="rowcheck"
802
+ checked={selectAll.checked}
803
+ indeterminate={selectAll.indeterminate}
804
+ disabled={!!source}
805
+ aria-label="Select all rows"
806
+ onclick={(e) => e.stopPropagation()}
807
+ onchange={toggleAll}
808
+ />
809
+ </span>
810
+ {/if}
811
+ {#each cols as col, ci (ci)}
812
+ <button
813
+ class="h"
814
+ class:right={isNumeric(col) || col.align === 'right'}
815
+ class:sortable={isSortable(col)}
816
+ class:dragging={ci === dragSrc}
817
+ class:dragover={ci === dragOver && ci !== dragSrc}
818
+ style={headStyle(ci)}
819
+ type="button"
820
+ role="columnheader"
821
+ aria-colindex={ci + 1 + selOffset}
822
+ draggable="true"
823
+ aria-sort={isSortable(col) && sortInfo(col.key)
824
+ ? sortInfo(col.key)?.dir === 'asc'
825
+ ? 'ascending'
826
+ : 'descending'
827
+ : 'none'}
828
+ onclick={(e) => toggleSort(col, e.shiftKey)}
829
+ ondragstart={(e) => {
830
+ dragSrc = ci;
831
+ e.dataTransfer?.setData('text/plain', String(ci));
832
+ }}
833
+ ondragover={(e) => {
834
+ e.preventDefault();
835
+ dragOver = ci;
836
+ }}
837
+ ondragleave={() => {
838
+ if (dragOver === ci) dragOver = -1;
839
+ }}
840
+ ondrop={(e) => {
841
+ e.preventDefault();
842
+ moveColumn(dragSrc, ci);
843
+ dragSrc = -1;
844
+ dragOver = -1;
845
+ }}
846
+ ondragend={() => {
847
+ dragSrc = -1;
848
+ dragOver = -1;
849
+ }}
850
+ >
851
+ <span class="label">{col.header}</span>
852
+ {#if isSortable(col) && sortInfo(col.key)}
853
+ {@const si = sortInfo(col.key)}
854
+ <span class="ind">
855
+ {si?.dir === 'asc' ? '▲' : '▼'}{#if sorts.length > 1}<span class="ord">{si?.pos}</span>{/if}
856
+ </span>
857
+ {/if}
858
+ {#if isResizable(col, resizable)}
859
+ <span
860
+ class="grip"
861
+ role="separator"
862
+ aria-orientation="vertical"
863
+ aria-label="Resize {col.header}"
864
+ onpointerdown={(e) => startResize(ci, e)}
865
+ ondblclick={(e) => resetWidth(ci, e)}
866
+ ondragstart={(e) => e.preventDefault()}
867
+ draggable="false"
868
+ ></span>
869
+ {/if}
870
+ </button>
871
+ {/each}
872
+ </div>
873
+
874
+ {#if filterRow && !source}
875
+ <div class="filter-row" role="row" bind:this={filterRowEl} style={pinned ? 'overflow:hidden;' : ''}>
876
+ {#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
877
+ {#each cols as col, ci (ci)}
878
+ <span class="fr-cell" style={cellWidthStyle(ci)}>
879
+ {#if col.type !== 'sparkline' && col.type !== 'custom'}
880
+ <input
881
+ class="fr-input"
882
+ type="search"
883
+ placeholder="filter…"
884
+ aria-label="Filter {col.header}"
885
+ value={colFilters[col.key] ?? ''}
886
+ oninput={(e) => (colFilters = { ...colFilters, [col.key]: e.currentTarget.value })}
887
+ />
888
+ {/if}
889
+ </span>
890
+ {/each}
891
+ </div>
892
+ {/if}
893
+
894
+ <div
895
+ class="viewport"
896
+ style="height:{height}px;{pinned ? 'overflow-x:auto;' : ''}"
897
+ bind:this={viewportEl}
898
+ onscroll={onScroll}
899
+ >
900
+ {#if pinnedRows.length > 0}
901
+ <div class="pinned-top">
902
+ {#each pinnedRows as prow, pi (getRowId(prow))}
903
+ <div class="row pinrow {rowClass?.(prow) ?? ''}" role="row" aria-hidden="true" style="height:{baseH}px;{rowWidthStyle}">
904
+ {#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
905
+ {#each cols as col, ci (ci)}
906
+ <Cell
907
+ {col}
908
+ row={prow}
909
+ r={-1 - pi}
910
+ c={ci}
911
+ colIndex={ci + 1 + selOffset}
912
+ cellId={`${gid}-pin${pi}-c${ci}`}
913
+ cellSnippet={cell}
914
+ pinned={pinned && layout.info[ci].pinned}
915
+ pinLeft={layout.info[ci].left + selOffset * SEL_W}
916
+ width={pinned ? layout.info[ci].width : undefined}
917
+ />
918
+ {/each}
919
+ </div>
920
+ {/each}
921
+ </div>
922
+ {/if}
923
+ {#if rowCount === 0 && !controller?.loading}
924
+ <div class="empty">No matching rows</div>
925
+ {/if}
926
+ {#if stickyGroups.length > 0}
927
+ <div class="sticky">
928
+ {#each stickyGroups as g (g.depth)}
929
+ <div class="sticky-row" aria-hidden="true" style="height:{baseH}px">
930
+ <GroupRow group={g} columns={cols} onToggle={toggleGroup} />
931
+ </div>
932
+ {/each}
933
+ </div>
934
+ {/if}
935
+ <div class="spacer" style="height:{total}px;{pinned ? `width:${layout.totalWidth + selOffset * SEL_W}px;` : ''}">
936
+ {#each renderItems as item (item.vr)}
937
+ {#if item.kind === 'group'}
938
+ <div class="grouprow" style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}">
939
+ {#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
940
+ <GroupRow group={item.group} columns={cols} onToggle={toggleGroup} rowIndex={item.vr + 2} />
941
+ </div>
942
+ {:else if item.kind === 'skeleton'}
943
+ <div class="row skeleton" role="row" aria-rowindex={item.vr + 2} aria-hidden="true" style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}">
944
+ {#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
945
+ {#each cols as col, ci (ci)}
946
+ <span class="c" style={cellWidthStyle(ci)}><span class="skelbar"></span></span>
947
+ {/each}
948
+ </div>
949
+ {:else}
950
+ <!-- Row activation is keyboard-accessible at the grid level: Enter on the focused cell fires onRowClick (focus is via aria-activedescendant). -->
951
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
952
+ <div class="row {rowClass?.(item.row) ?? ''}" class:alt={item.vr % 2 === 1} class:rowsel={rowSelection && isRowSelected(getRowId(item.row))} class:clickable={!!onRowClick} role="row" tabindex="-1" aria-rowindex={item.vr + 2} aria-selected={rowSelection ? isRowSelected(getRowId(item.row)) : undefined} style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}" onclick={(e) => onRowClick?.(item.row, e)}>
953
+ {#if rowSelection}
954
+ <span class="selcell" style={selCellStyle(false)}>
955
+ <input
956
+ type="checkbox"
957
+ class="rowcheck"
958
+ checked={isRowSelected(getRowId(item.row))}
959
+ aria-label="Select row"
960
+ onpointerdown={(e) => e.stopPropagation()}
961
+ onclick={(e) => e.stopPropagation()}
962
+ onchange={() => toggleRow(getRowId(item.row))}
963
+ />
964
+ </span>
965
+ {/if}
966
+ {#each cols as col, ci (ci)}
967
+ <Cell
968
+ {col}
969
+ row={item.row}
970
+ r={item.vr}
971
+ c={ci}
972
+ colIndex={ci + 1 + selOffset}
973
+ cellId={`${gid}-r${item.vr}-c${ci}`}
974
+ cellSnippet={cell}
975
+ selected={sel.contains(item.vr, ci)}
976
+ focused={sel.isFocus(item.vr, ci)}
977
+ pinned={pinned && layout.info[ci].pinned}
978
+ pinLeft={layout.info[ci].left + selOffset * SEL_W}
979
+ width={pinned ? layout.info[ci].width : undefined}
980
+ alt={item.vr % 2 === 1}
981
+ editing={editing?.r === item.vr && editing?.c === ci}
982
+ {onCellDown}
983
+ {onCellEnter}
984
+ onCellClick={onCellClick ? onCellClicked : undefined}
985
+ onCellDblClick={startEdit}
986
+ onEditCommit={(raw) => commitEdit(item.vr, ci, raw)}
987
+ onEditCancel={() => (editing = null)}
988
+ />
989
+ {/each}
990
+ </div>
991
+ {/if}
992
+ {/each}
993
+ </div>
994
+ {#if footerCells}
995
+ <div class="footer" role="row" style={pinned ? `width:${layout.totalWidth + selOffset * SEL_W}px;` : ''}>
996
+ {#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
997
+ {#each cols as col, ci (ci)}
998
+ <span class="fcell" class:right={isNumeric(col)} style={cellWidthStyle(ci)}>
999
+ {ci === 0 && !footerCells[ci] ? 'Total' : footerCells[ci]}
1000
+ </span>
1001
+ {/each}
1002
+ </div>
1003
+ {/if}
1004
+ </div>
1005
+
1006
+ <AggregationBar result={agg} kinds={aggregations} />
1007
+ </div>
1008
+
1009
+ <style>
1010
+ .grid {
1011
+ --bo-bg: var(--bo-grid-bg, #1a1a1a);
1012
+ --bo-header-bg: var(--bo-grid-header-bg, #0f0f0f);
1013
+ --bo-row-a: var(--bo-grid-row-a, #131313);
1014
+ --bo-row-b: var(--bo-grid-row-b, #0f0f0f);
1015
+ --bo-row-hover: var(--bo-grid-row-hover, #1f1f24);
1016
+ --bo-text: var(--bo-grid-text, #e5e5e5);
1017
+ --bo-text-dim: var(--bo-grid-text-dim, #8a8a8a);
1018
+ --bo-border: var(--bo-grid-border, rgba(255, 255, 255, 0.06));
1019
+ --bo-up: var(--bo-grid-up, #34d399);
1020
+ --bo-down: var(--bo-grid-down, #f87171);
1021
+ --bo-amber: var(--bo-grid-amber, #f59e0b);
1022
+ --bo-sel-fill: var(--bo-grid-sel-fill, rgba(99, 102, 241, 0.16));
1023
+ --bo-sel-border: var(--bo-grid-sel-border, #6366f1);
1024
+ --bo-header-h: var(--bo-grid-header-h, 28px);
1025
+ --bo-mono: var(--bo-grid-mono, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace);
1026
+ --bo-sans: var(--bo-grid-sans, Inter, "Segoe UI", system-ui, sans-serif);
1027
+
1028
+ display: flex;
1029
+ flex-direction: column;
1030
+ color: var(--bo-text);
1031
+ font-family: var(--bo-sans);
1032
+ background: var(--bo-bg);
1033
+ border: 0.5px solid var(--bo-border);
1034
+ border-radius: 8px;
1035
+ overflow: hidden;
1036
+ outline: none;
1037
+ }
1038
+ .grid:focus-visible {
1039
+ border-color: var(--bo-sel-border);
1040
+ }
1041
+
1042
+ /* Spanning header groups (row above the column headers). */
1043
+ .head-groups {
1044
+ display: flex;
1045
+ align-items: stretch;
1046
+ height: var(--bo-header-h);
1047
+ background: var(--bo-header-bg);
1048
+ border-bottom: 0.5px solid var(--bo-border);
1049
+ }
1050
+ .head-groups .hg {
1051
+ display: flex;
1052
+ align-items: center;
1053
+ justify-content: center;
1054
+ min-width: 0;
1055
+ padding: 0 8px;
1056
+ font-size: 10px;
1057
+ font-weight: 600;
1058
+ letter-spacing: 0.04em;
1059
+ text-transform: uppercase;
1060
+ color: var(--bo-text);
1061
+ border-right: 0.5px solid var(--bo-border);
1062
+ overflow: hidden;
1063
+ white-space: nowrap;
1064
+ }
1065
+ .head-groups .hg.empty {
1066
+ border-right: 0;
1067
+ background: transparent;
1068
+ }
1069
+
1070
+ .head {
1071
+ display: flex;
1072
+ align-items: stretch;
1073
+ height: var(--bo-header-h);
1074
+ background: var(--bo-header-bg);
1075
+ border-bottom: 0.5px solid var(--bo-border);
1076
+ }
1077
+ .h {
1078
+ position: relative;
1079
+ display: flex;
1080
+ align-items: center;
1081
+ gap: 4px;
1082
+ padding: 0 8px;
1083
+ min-width: 0;
1084
+ font: inherit;
1085
+ font-size: 11px;
1086
+ letter-spacing: 0.03em;
1087
+ text-transform: uppercase;
1088
+ color: var(--bo-text-dim);
1089
+ background: transparent;
1090
+ border: 0;
1091
+ cursor: grab;
1092
+ }
1093
+ /* Drag-to-resize grip: a thin hit-target straddling the column's right edge. */
1094
+ .h .grip {
1095
+ position: absolute;
1096
+ top: 0;
1097
+ right: -3px;
1098
+ width: 7px;
1099
+ height: 100%;
1100
+ cursor: col-resize;
1101
+ z-index: 6;
1102
+ touch-action: none;
1103
+ }
1104
+ .h .grip::after {
1105
+ content: '';
1106
+ position: absolute;
1107
+ top: 20%;
1108
+ right: 3px;
1109
+ width: 1px;
1110
+ height: 60%;
1111
+ background: var(--bo-border);
1112
+ opacity: 0;
1113
+ transition: opacity 120ms;
1114
+ }
1115
+ .h .grip:hover::after,
1116
+ .h .grip:active::after {
1117
+ opacity: 1;
1118
+ background: var(--bo-sel-border);
1119
+ }
1120
+ .h.sortable {
1121
+ cursor: pointer;
1122
+ }
1123
+ .h.sortable:hover {
1124
+ color: var(--bo-text);
1125
+ background: var(--bo-row-hover);
1126
+ }
1127
+ .h.dragging {
1128
+ opacity: 0.4;
1129
+ }
1130
+ .h.dragover {
1131
+ box-shadow: inset 2px 0 0 var(--bo-sel-border);
1132
+ }
1133
+ .h.right {
1134
+ justify-content: flex-end;
1135
+ }
1136
+ .h .label {
1137
+ overflow: hidden;
1138
+ white-space: nowrap;
1139
+ text-overflow: ellipsis;
1140
+ pointer-events: none;
1141
+ }
1142
+ .h .ind {
1143
+ display: inline-flex;
1144
+ align-items: center;
1145
+ gap: 1px;
1146
+ font-size: 9px;
1147
+ color: var(--bo-text);
1148
+ }
1149
+ .h .ind .ord {
1150
+ font-size: 8px;
1151
+ line-height: 1;
1152
+ color: var(--bo-text-dim);
1153
+ font-variant-numeric: tabular-nums;
1154
+ }
1155
+
1156
+ /* Per-column filter input row, under the header. */
1157
+ .filter-row {
1158
+ display: flex;
1159
+ align-items: stretch;
1160
+ height: 30px;
1161
+ border-bottom: 0.5px solid var(--bo-border);
1162
+ background: var(--bo-header-bg);
1163
+ }
1164
+ .filter-row .fr-cell {
1165
+ display: flex;
1166
+ align-items: center;
1167
+ padding: 0 4px;
1168
+ min-width: 0;
1169
+ }
1170
+ .filter-row .fr-input {
1171
+ width: 100%;
1172
+ min-width: 0;
1173
+ padding: 2px 6px;
1174
+ font: inherit;
1175
+ font-family: var(--bo-mono);
1176
+ font-size: 11px;
1177
+ color: var(--bo-text);
1178
+ background: var(--bo-bg);
1179
+ border: 0.5px solid var(--bo-border);
1180
+ border-radius: 4px;
1181
+ outline: none;
1182
+ }
1183
+ .filter-row .fr-input:focus {
1184
+ border-color: var(--bo-sel-border);
1185
+ }
1186
+
1187
+ .viewport {
1188
+ position: relative;
1189
+ overflow-y: auto;
1190
+ overflow-x: hidden;
1191
+ user-select: none;
1192
+ }
1193
+ .empty {
1194
+ position: absolute;
1195
+ inset: 0;
1196
+ display: flex;
1197
+ align-items: center;
1198
+ justify-content: center;
1199
+ color: var(--bo-text-dim);
1200
+ font-size: 13px;
1201
+ }
1202
+ .spacer {
1203
+ position: relative;
1204
+ width: 100%;
1205
+ }
1206
+
1207
+ .sticky {
1208
+ position: sticky;
1209
+ top: 0;
1210
+ height: 0;
1211
+ z-index: 3;
1212
+ overflow: visible;
1213
+ }
1214
+ .sticky-row {
1215
+ position: relative;
1216
+ left: 0;
1217
+ right: 0;
1218
+ }
1219
+ .sticky-row:last-child {
1220
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.28);
1221
+ }
1222
+
1223
+ .row,
1224
+ .grouprow {
1225
+ position: absolute;
1226
+ left: 0;
1227
+ right: 0;
1228
+ }
1229
+ .row {
1230
+ display: flex;
1231
+ align-items: stretch;
1232
+ background: var(--bo-row-b);
1233
+ border-bottom: 0.5px solid var(--bo-border);
1234
+ }
1235
+ .row.alt {
1236
+ background: var(--bo-row-a);
1237
+ }
1238
+ .row:not(.skeleton):hover {
1239
+ background: var(--bo-row-hover);
1240
+ }
1241
+ .row.rowsel {
1242
+ background: var(--bo-sel-fill);
1243
+ }
1244
+ .row.clickable {
1245
+ cursor: pointer;
1246
+ }
1247
+
1248
+ /* Pinned top rows: stick to the top of the viewport above the scroll. */
1249
+ .pinned-top {
1250
+ position: sticky;
1251
+ top: 0;
1252
+ z-index: 5;
1253
+ background: var(--bo-bg);
1254
+ box-shadow: 0 1px 0 var(--bo-border);
1255
+ }
1256
+ .pinned-top .pinrow {
1257
+ display: flex;
1258
+ align-items: stretch;
1259
+ min-width: 100%;
1260
+ background: color-mix(in srgb, var(--bo-header-bg) 70%, var(--bo-sel-fill) 60%);
1261
+ border-bottom: 0.5px solid var(--bo-border);
1262
+ }
1263
+
1264
+ /* Pinned totals row: sticks to the bottom of the viewport. */
1265
+ .footer {
1266
+ position: sticky;
1267
+ bottom: 0;
1268
+ z-index: 4;
1269
+ display: flex;
1270
+ align-items: stretch;
1271
+ min-width: 100%;
1272
+ height: 32px;
1273
+ background: var(--bo-header-bg);
1274
+ border-top: 0.5px solid var(--bo-border);
1275
+ }
1276
+ .footer .fcell {
1277
+ display: flex;
1278
+ align-items: center;
1279
+ padding: 0 8px;
1280
+ font-family: var(--bo-mono);
1281
+ font-size: 11px;
1282
+ font-weight: 600;
1283
+ font-variant-numeric: tabular-nums;
1284
+ color: var(--bo-text);
1285
+ overflow: hidden;
1286
+ white-space: nowrap;
1287
+ }
1288
+ .footer .fcell.right {
1289
+ justify-content: flex-end;
1290
+ }
1291
+
1292
+ /* Leading checkbox column (row selection). */
1293
+ .selcell {
1294
+ display: flex;
1295
+ align-items: center;
1296
+ justify-content: center;
1297
+ flex: 0 0 auto;
1298
+ }
1299
+ .selhead {
1300
+ border-bottom: 0.5px solid var(--bo-border);
1301
+ }
1302
+ .rowcheck {
1303
+ width: 14px;
1304
+ height: 14px;
1305
+ cursor: pointer;
1306
+ accent-color: var(--bo-sel-border);
1307
+ }
1308
+ .rowcheck:disabled {
1309
+ cursor: not-allowed;
1310
+ opacity: 0.4;
1311
+ }
1312
+
1313
+ .skeleton .c {
1314
+ display: flex;
1315
+ align-items: center;
1316
+ padding: 0 8px;
1317
+ }
1318
+ .skelbar {
1319
+ width: 60%;
1320
+ height: 8px;
1321
+ border-radius: 4px;
1322
+ background: linear-gradient(90deg, var(--bo-row-hover), var(--bo-border), var(--bo-row-hover));
1323
+ background-size: 200% 100%;
1324
+ animation: shimmer 1.1s linear infinite;
1325
+ }
1326
+ @keyframes shimmer {
1327
+ 0% {
1328
+ background-position: 200% 0;
1329
+ }
1330
+ 100% {
1331
+ background-position: -200% 0;
1332
+ }
1333
+ }
1334
+ @media (prefers-reduced-motion: reduce) {
1335
+ .skelbar {
1336
+ animation: none;
1337
+ }
1338
+ }
1339
+ </style>