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.
@@ -14,14 +14,25 @@
14
14
  import { Selection } from './selection.svelte';
15
15
  import { aggregate, type AggKind, type AggResult } from './aggregate';
16
16
  import { buildFlatRows, activeGroupsAt, type VisualRow, type GroupNode } from './grouping';
17
+ import { buildTreeRows } from './tree';
17
18
  import { moveIndex } from './reorder';
18
19
  import { parseClipboard, isSingleCell } from './clipboard';
19
20
  import { applyWidths, clampWidth, isResizable, type WidthMap } from './sizing';
21
+ import {
22
+ passesFilters,
23
+ isFilterActive,
24
+ defaultFilterKind,
25
+ distinctValues,
26
+ type ColumnFilter,
27
+ type FilterKind,
28
+ } from './filtering';
20
29
  import type { RowSource } from './source';
21
30
  import { RowSourceController } from './source.svelte';
22
31
  import Cell from './Cell.svelte';
23
32
  import GroupRow from './GroupRow.svelte';
24
33
  import AggregationBar from './AggregationBar.svelte';
34
+ import Pager from './Pager.svelte';
35
+ import RowMenu from './RowMenu.svelte';
25
36
 
26
37
  let {
27
38
  rows,
@@ -39,15 +50,36 @@
39
50
  rowSelection = false,
40
51
  onRowSelectionChange,
41
52
  hiddenColumns = [],
53
+ onColumnVisibilityChange,
54
+ columnMenu = false,
55
+ columnsPanel = false,
42
56
  rowClass,
43
57
  getRowId = (r: GridRow) => r.id,
44
58
  onRowClick,
45
59
  sort,
46
60
  onSortChange,
61
+ columnFilters,
62
+ onFilterChange,
47
63
  footer = false,
48
64
  onCellClick,
49
65
  pinnedRows = [],
50
66
  filterRow = false,
67
+ filterMenu = false,
68
+ quickFilter = false,
69
+ fillHandle = false,
70
+ emptyMessage = 'No matching rows',
71
+ loading = false,
72
+ rowMenu,
73
+ detail,
74
+ detailHeight = 160,
75
+ getChildren,
76
+ onRowReorder,
77
+ pageSize = 0,
78
+ page,
79
+ onPageChange,
80
+ onColumnReorder,
81
+ onColumnResize,
82
+ ariaLabel,
51
83
  cell,
52
84
  }: {
53
85
  rows: GridRow[];
@@ -67,8 +99,19 @@
67
99
  /** Called with the selected row ids whenever the row-selection set changes. */
68
100
  onRowSelectionChange?: (selectedIds: Array<string | number>) => void;
69
101
  /** Column keys to hide (controlled). Build your own column-picker UI and
70
- drive this prop — the grid stays presentation-only. */
102
+ drive this prop — the grid stays presentation-only. Composed (union) with
103
+ columns the user hides at runtime via the column menu. */
71
104
  hiddenColumns?: string[];
105
+ /** Called with all currently-hidden column keys whenever the runtime set
106
+ changes (column menu hide/show). */
107
+ onColumnVisibilityChange?: (hidden: string[]) => void;
108
+ /** Enable a per-column header menu (a ⋮ trigger) with sort, hide and (with
109
+ `filterMenu`) filter actions. Default false. */
110
+ columnMenu?: boolean;
111
+ /** Show a "Columns" button that opens a panel to toggle column visibility
112
+ (the place to restore columns hidden via the menu). Lazy-loaded. Default
113
+ false. */
114
+ columnsPanel?: boolean;
72
115
  /** Return extra CSS class(es) for a data row (e.g. to colour by value).
73
116
  Style them via `:global(.your-class)` since rows live inside the grid. */
74
117
  rowClass?: (row: GridRow) => string | undefined;
@@ -84,6 +127,12 @@
84
127
  sort?: SortState[];
85
128
  /** Called with the new sort order whenever a header is clicked. */
86
129
  onSortChange?: (sort: SortState[]) => void;
130
+ /** Controlled column filters (keyed by column key). When set, the grid
131
+ reflects these and reports changes via `onFilterChange` instead of holding
132
+ its own. Omit for uncontrolled filtering. */
133
+ columnFilters?: Record<string, ColumnFilter>;
134
+ /** Called with the full column-filter map whenever a header filter changes. */
135
+ onFilterChange?: (filters: Record<string, ColumnFilter>) => void;
87
136
  /** Show a pinned totals row: each column with a `groupAgg` shows that
88
137
  aggregate over all (filtered) rows. In-memory mode only. Default false. */
89
138
  footer?: boolean;
@@ -99,6 +148,54 @@
99
148
  /** Show a per-column filter input row under the header. Rows must match every
100
149
  non-empty column filter (AND). In-memory mode only. Default false. */
101
150
  filterRow?: boolean;
151
+ /** Enable a per-column header filter menu (lazy-loaded on first open). Each
152
+ filterable column shows a funnel; the menu's control matches the column
153
+ type (text/number/date). Override or disable per column with `col.filter`.
154
+ Works in source mode too (filters are delegated to the `RowSource`); set
155
+ filters need in-memory data. Default false. */
156
+ filterMenu?: boolean;
157
+ /** Show a built-in quick-filter search box above the grid that matches across
158
+ all column values (ANDed with the `filter` prop). In-memory mode only.
159
+ Default false. */
160
+ quickFilter?: boolean;
161
+ /** Show an Excel-style fill handle at the selection's bottom-right corner;
162
+ drag it to copy the selected value(s) across the extended range (editable
163
+ columns only). In-memory mode only. Default false. */
164
+ fillHandle?: boolean;
165
+ /** Message shown when there are no rows. Default 'No matching rows'. */
166
+ emptyMessage?: string;
167
+ /** Show a loading overlay over the grid (for consumer-driven async work in
168
+ in-memory mode; source mode shows skeleton rows automatically). */
169
+ loading?: boolean;
170
+ /** Right-click row menu. Return the items for a row; an empty array shows no
171
+ menu. Each item runs `onSelect` and closes the menu. */
172
+ rowMenu?: (row: GridRow) => Array<{ label: string; onSelect: () => void }>;
173
+ /** Master-detail: render an expandable detail panel under a row. Adds a
174
+ leading expand-toggle column. In-memory mode only (overrides rowHeight). */
175
+ detail?: Snippet<[{ row: GridRow }]>;
176
+ /** Height (px) of the expanded detail panel. Default 160. */
177
+ detailHeight?: number;
178
+ /** Tree data: return a row's children (undefined/empty = leaf). When set,
179
+ `rows` are the roots; the grid renders an indented, expandable tree.
180
+ In-memory mode; filter/sort/group/paginate are not applied to the tree. */
181
+ getChildren?: (row: GridRow) => GridRow[] | undefined;
182
+ /** Enable drag-to-reorder rows via a handle in the first column. Called with
183
+ the from/to indices (into the visible rows) on drop — reorder your own
184
+ `rows` in here. Flat, unsorted, in-memory lists only. */
185
+ onRowReorder?: (fromIndex: number, toIndex: number) => void;
186
+ /** Rows per page. When > 0 (in-memory mode), shows a pager instead of one
187
+ long scroll; rows still virtualize within a page. Default 0 (off). */
188
+ pageSize?: number;
189
+ /** Controlled current page (0-based). Omit for uncontrolled paging. */
190
+ page?: number;
191
+ /** Called with the new page index when the pager is used. */
192
+ onPageChange?: (page: number) => void;
193
+ /** Called with the new column-key order after a header drag-reorder. */
194
+ onColumnReorder?: (keys: string[]) => void;
195
+ /** Called with a column key + new width after a drag-resize. */
196
+ onColumnResize?: (key: string, width: number) => void;
197
+ /** Accessible name for the grid (`aria-label` on the `role="grid"` root). */
198
+ ariaLabel?: string;
102
199
  filter?: string;
103
200
  groupBy?: string[];
104
201
  aggregations?: AggKind[];
@@ -134,33 +231,92 @@
134
231
 
135
232
  const sel = new Selection();
136
233
  let dragging = $state(false);
234
+ // Fill handle: drag the selection's corner to copy its value(s) across.
235
+ let filling = $state(false);
236
+ let fillTo = $state<{ r: number; c: number } | null>(null);
237
+ let fillSource: { r0: number; c0: number; r1: number; c1: number } | null = null;
137
238
  let editing = $state<{ r: number; c: number } | null>(null);
239
+ // When editing was opened by typing a character (type-to-edit), the editor
240
+ // seeds its input with it; null means edit the existing value (dblclick/Enter).
241
+ let editSeed = $state<string | null>(null);
138
242
 
139
- function startEdit(r: number, c: number) {
243
+ function startEdit(r: number, c: number, seed: string | null = null) {
140
244
  if (!isEditable(cols[c]) || !dataAt(r)) return;
245
+ editSeed = seed;
141
246
  editing = { r, c };
142
247
  }
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.
248
+ // ---- Edit history (undo / redo) -------------------------------------------
249
+ // The grid is controlled (the consumer owns the data via onCellEdit), so undo
250
+ // re-emits onCellEdit with the previous value. History is keyed by row object
251
+ // reference + column, so it survives sort/filter/reorder. Multi-cell ops
252
+ // (paste, fill) record as one grouped step.
253
+ type EditCell = { row: GridRow; col: ColumnDef; old: string | number; value: string | number };
254
+ const UNDO_LIMIT = 100;
255
+ let undoStack: EditCell[][] = [];
256
+ let redoStack: EditCell[][] = [];
257
+ let currentBatch: EditCell[] | null = null;
258
+ function pushUndo(group: EditCell[]): void {
259
+ undoStack.push(group);
260
+ if (undoStack.length > UNDO_LIMIT) undoStack.shift();
261
+ redoStack = [];
262
+ }
263
+ /** Group every writeCell inside `fn` into a single undo step. */
264
+ function batch(fn: () => void): void {
265
+ const prev = currentBatch;
266
+ currentBatch = [];
267
+ fn();
268
+ const group = currentBatch;
269
+ currentBatch = prev;
270
+ if (group.length) pushUndo(group);
271
+ }
272
+
273
+ // Coerce + validate a raw string for cell (r,c) and emit onCellEdit, recording
274
+ // it for undo. Returns true if written, false if rejected (not editable,
275
+ // missing row, or invalid number). Shared by inline edit, paste and fill.
146
276
  function writeCell(r: number, c: number, raw: string): boolean {
147
277
  const col = cols[c];
148
278
  if (!col || !isEditable(col)) return false;
149
279
  const row = dataAt(r);
150
280
  if (!row) return false;
151
281
  let value: string | number = raw;
152
- if (isNumeric(col)) {
282
+ if (col.type === 'date') {
283
+ // The date editor emits a yyyy-mm-dd string; store the column's ms value.
284
+ const ms = Date.parse(`${raw}T00:00:00Z`);
285
+ if (!Number.isFinite(ms)) return false;
286
+ value = ms;
287
+ } else if (isNumeric(col)) {
153
288
  const n = Number(raw);
154
289
  if (!Number.isFinite(n)) return false; // reject invalid number, keep old value
155
290
  value = n;
156
291
  }
157
292
  if (col.validate && !col.validate(value, row)) return false; // consumer rejected it
293
+ const old = (row[col.key] ?? '') as string | number;
158
294
  onCellEdit?.({ row, column: col, value });
295
+ if (onCellEdit) {
296
+ const entry: EditCell = { row, col, old, value };
297
+ if (currentBatch) currentBatch.push(entry);
298
+ else pushUndo([entry]);
299
+ }
159
300
  return true;
160
301
  }
302
+ function undo(): void {
303
+ const group = undoStack.pop();
304
+ if (!group) return;
305
+ for (const e of group) onCellEdit?.({ row: e.row, column: e.col, value: e.old });
306
+ redoStack.push(group);
307
+ editing = null;
308
+ }
309
+ function redo(): void {
310
+ const group = redoStack.pop();
311
+ if (!group) return;
312
+ for (const e of group) onCellEdit?.({ row: e.row, column: e.col, value: e.value });
313
+ undoStack.push(group);
314
+ editing = null;
315
+ }
161
316
 
162
317
  function commitEdit(r: number, c: number, raw: string) {
163
318
  editing = null;
319
+ editSeed = null;
164
320
  writeCell(r, c, raw);
165
321
  }
166
322
 
@@ -178,12 +334,46 @@
178
334
  // Per-column filter text (filterRow), keyed by column key.
179
335
  let colFilters = $state<Record<string, string>>({});
180
336
 
337
+ // Structured per-column filters from the header filter menu (v0.3), keyed by
338
+ // column key. Menu filters take precedence over the filterRow text inputs.
339
+ // Controlled by the `columnFilters` prop when provided, else internal state.
340
+ let internalColumnFilters = $state<Record<string, ColumnFilter>>({});
341
+ const activeColumnFilters = $derived(columnFilters ?? internalColumnFilters);
342
+ function setColumnFilters(next: Record<string, ColumnFilter>): void {
343
+ if (columnFilters === undefined) internalColumnFilters = next; // uncontrolled: own it
344
+ onFilterChange?.(next); // always notify
345
+ }
346
+
347
+ // Built-in quick-filter text (global, matches across all columns).
348
+ let quickText = $state('');
349
+
181
350
  // Whole-row selection (opt-in), keyed by row id so it survives sort/filter.
182
351
  // Plain Set + a version counter for reactivity (same pattern as `collapsed`).
183
352
  const SEL_W = 40; // checkbox column width (px)
353
+ const EXP_W = 30; // master-detail expand column width (px)
184
354
  const selectedRows = new Set<string | number>();
185
355
  let selRowsVersion = $state(0);
356
+
357
+ // Master-detail expansion (opt-in via `detail`), keyed by row id.
358
+ const expandedRows = new Set<string | number>();
359
+ let expVersion = $state(0);
360
+ const expandable = $derived(!!detail && !source);
361
+
362
+ // Leading fixed columns: expand toggle (if any) then checkbox (if any).
186
363
  const selOffset = $derived(rowSelection ? 1 : 0);
364
+ const expOffset = $derived(expandable ? 1 : 0);
365
+ const leadCols = $derived(selOffset + expOffset); // count, for aria indices
366
+ const leadPx = $derived(selOffset * SEL_W + expOffset * EXP_W); // px, for sticky offsets
367
+
368
+ function isExpanded(id: string | number): boolean {
369
+ expVersion; // track
370
+ return expandedRows.has(id);
371
+ }
372
+ function toggleExpand(id: string | number): void {
373
+ if (expandedRows.has(id)) expandedRows.delete(id);
374
+ else expandedRows.add(id);
375
+ expVersion++;
376
+ }
187
377
 
188
378
  function isRowSelected(id: string | number): boolean {
189
379
  selRowsVersion; // track
@@ -197,17 +387,30 @@
197
387
  }
198
388
 
199
389
  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.
390
+ // Columns hidden at runtime via the column menu, unioned with the controlled
391
+ // `hiddenColumns` prop.
392
+ let runtimeHidden = $state<string[]>([]);
393
+ const effectiveHidden = $derived(
394
+ runtimeHidden.length ? [...new Set([...hiddenColumns, ...runtimeHidden])] : hiddenColumns,
395
+ );
396
+ // Drop hidden columns. Applied after ordering so `order` stays indexed over the
397
+ // full column set.
202
398
  const visible = $derived(
203
- hiddenColumns.length ? ordered.filter((c) => !hiddenColumns.includes(c.key)) : ordered,
399
+ effectiveHidden.length ? ordered.filter((c) => !effectiveHidden.includes(c.key)) : ordered,
204
400
  );
205
401
  // Apply any resize overrides (turns the dragged column fixed-width), then
206
402
  // pin-arrange. Both are no-ops by default, so the grid stays fit-to-width.
207
403
  const sized = $derived(applyWidths(visible, widths));
208
- // Pin-arrangement: pinned columns move to the front and get sticky offsets.
404
+ // Runtime pin overrides (column menu) layered on top of static `col.pinned`.
405
+ let pinOverrides = $state<Record<string, 'left' | 'right' | false>>({});
406
+ const pinnedSized = $derived(
407
+ Object.keys(pinOverrides).length
408
+ ? sized.map((c) => (c.key in pinOverrides ? { ...c, pinned: pinOverrides[c.key] } : c))
409
+ : sized,
410
+ );
411
+ // Pin-arrangement: pinned columns move to the edges and get sticky offsets.
209
412
  // When nothing is pinned this is a no-op and the grid stays fit-to-width.
210
- const layout = $derived(arrangePinned(sized));
413
+ const layout = $derived(arrangePinned(pinnedSized));
211
414
  const cols = $derived(layout.columns);
212
415
  const pinned = $derived(layout.anyPinned);
213
416
 
@@ -237,24 +440,38 @@
237
440
 
238
441
  // Pinned columns sit to the right of the (also-sticky) checkbox column, so
239
442
  // their sticky-left offsets shift by SEL_W when row selection is on.
443
+ // Sticky position for a pinned column: left columns offset past the (also
444
+ // sticky) checkbox column; right columns offset from the right edge.
445
+ function pinStick(ci: number): string {
446
+ const inf = layout.info[ci];
447
+ if (inf.side === 'right') return `position:sticky;right:${inf.right}px;`;
448
+ return `position:sticky;left:${inf.left + leadPx}px;`;
449
+ }
240
450
  function headStyle(ci: number): string {
241
451
  if (!pinned) return colStyle(cols[ci]);
242
452
  const inf = layout.info[ci];
243
453
  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);`;
454
+ if (inf.pinned) s += `${pinStick(ci)}z-index:5;background:var(--bo-header-bg);`;
245
455
  return s;
246
456
  }
247
457
  function cellWidthStyle(ci: number): string {
248
458
  if (!pinned) return colStyle(cols[ci]);
249
459
  const inf = layout.info[ci];
250
460
  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);`;
461
+ if (inf.pinned) s += `${pinStick(ci)}z-index:1;background:var(--bo-bg);`;
252
462
  return s;
253
463
  }
254
- // The leading checkbox column: a fixed-width flex item, sticky-left when the
255
- // grid scrolls horizontally (pinned mode).
464
+ // The leading checkbox column: a fixed-width flex item, sticky-left (past the
465
+ // expand column, if any) when the grid scrolls horizontally (pinned mode).
256
466
  function selCellStyle(header: boolean): string {
257
467
  let s = `flex:0 0 ${SEL_W}px;width:${SEL_W}px;`;
468
+ if (pinned)
469
+ s += `position:sticky;left:${expOffset * EXP_W}px;z-index:${header ? 6 : 2};background:var(--bo-${header ? 'header-bg' : 'bg'});`;
470
+ return s;
471
+ }
472
+ // The leading expand-toggle column (master-detail), sticky at the far left.
473
+ function expandCellStyle(header: boolean): string {
474
+ let s = `flex:0 0 ${EXP_W}px;width:${EXP_W}px;`;
258
475
  if (pinned)
259
476
  s += `position:sticky;left:0;z-index:${header ? 6 : 2};background:var(--bo-${header ? 'header-bg' : 'bg'});`;
260
477
  return s;
@@ -301,6 +518,7 @@
301
518
  /* storage unavailable — order still applies this session */
302
519
  }
303
520
  }
521
+ onColumnReorder?.(next.map((i) => columns[i].key));
304
522
  }
305
523
 
306
524
  // ---- Column resizing -------------------------------------------------------
@@ -331,7 +549,106 @@
331
549
  });
332
550
  });
333
551
 
334
- let resize: { key: string; startX: number; startW: number } | null = null;
552
+ // ---- Runtime column visibility (column menu) ------------------------------
553
+ function hiddenStorageKey(): string | null {
554
+ return persistKey ? `bo-grid:hidden:${persistKey}` : null;
555
+ }
556
+ $effect(() => {
557
+ persistKey;
558
+ untrack(() => {
559
+ const key = hiddenStorageKey();
560
+ if (!key || typeof localStorage === 'undefined') return;
561
+ try {
562
+ const saved = JSON.parse(localStorage.getItem(key) ?? 'null');
563
+ if (Array.isArray(saved)) runtimeHidden = saved.filter((k) => typeof k === 'string');
564
+ } catch {
565
+ /* corrupt value — ignore */
566
+ }
567
+ });
568
+ });
569
+ function setRuntimeHidden(next: string[]): void {
570
+ runtimeHidden = next;
571
+ const key = hiddenStorageKey();
572
+ if (key && typeof localStorage !== 'undefined') {
573
+ try {
574
+ localStorage.setItem(key, JSON.stringify(next));
575
+ } catch {
576
+ /* storage unavailable — still applies this session */
577
+ }
578
+ }
579
+ onColumnVisibilityChange?.(effectiveHidden);
580
+ }
581
+ function hideColumn(key: string): void {
582
+ if (!runtimeHidden.includes(key)) setRuntimeHidden([...runtimeHidden, key]);
583
+ }
584
+ function showColumn(key: string): void {
585
+ if (runtimeHidden.includes(key)) setRuntimeHidden(runtimeHidden.filter((k) => k !== key));
586
+ }
587
+ function toggleColumnVisible(key: string): void {
588
+ if (effectiveHidden.includes(key)) showColumn(key);
589
+ else hideColumn(key);
590
+ }
591
+ function showAllColumns(): void {
592
+ setRuntimeHidden([]);
593
+ }
594
+ // Columns tool panel (lazy-loaded checklist), anchored at the toolbar button.
595
+ let ToolPanelComp = $state<typeof import('./ToolPanel.svelte').default | null>(null);
596
+ let panelXY = $state<{ x: number; y: number } | null>(null);
597
+ async function openToolPanel(e: Event) {
598
+ e.stopPropagation();
599
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
600
+ if (!ToolPanelComp) ToolPanelComp = (await import('./ToolPanel.svelte')).default;
601
+ panelXY = { x: rect.left, y: rect.bottom + 2 };
602
+ }
603
+ $effect(() => {
604
+ if (!panelXY) return;
605
+ const close = () => (panelXY = null);
606
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && (panelXY = null);
607
+ window.addEventListener('pointerdown', close);
608
+ window.addEventListener('keydown', onKey);
609
+ window.addEventListener('blur', close);
610
+ return () => {
611
+ window.removeEventListener('pointerdown', close);
612
+ window.removeEventListener('keydown', onKey);
613
+ window.removeEventListener('blur', close);
614
+ };
615
+ });
616
+
617
+ // ---- Runtime column pinning (column menu) ---------------------------------
618
+ function pinStorageKey(): string | null {
619
+ return persistKey ? `bo-grid:pin:${persistKey}` : null;
620
+ }
621
+ $effect(() => {
622
+ persistKey;
623
+ untrack(() => {
624
+ const key = pinStorageKey();
625
+ if (!key || typeof localStorage === 'undefined') return;
626
+ try {
627
+ const saved = JSON.parse(localStorage.getItem(key) ?? 'null');
628
+ if (saved && typeof saved === 'object') pinOverrides = saved as typeof pinOverrides;
629
+ } catch {
630
+ /* corrupt value — ignore */
631
+ }
632
+ });
633
+ });
634
+ function setPinOverride(key: string, side: 'left' | 'right' | false): void {
635
+ pinOverrides = { ...pinOverrides, [key]: side };
636
+ const sk = pinStorageKey();
637
+ if (sk && typeof localStorage !== 'undefined') {
638
+ try {
639
+ localStorage.setItem(sk, JSON.stringify(pinOverrides));
640
+ } catch {
641
+ /* storage unavailable — still applies this session */
642
+ }
643
+ }
644
+ }
645
+ // Effective pin side for a column key: runtime override, else static config.
646
+ function pinSideOf(col: ColumnDef): 'left' | 'right' | false {
647
+ if (col.key in pinOverrides) return pinOverrides[col.key];
648
+ return col.pinned === true ? 'left' : col.pinned || false;
649
+ }
650
+
651
+ let resize: { key: string; startX: number; startW: number; min?: number; max?: number } | null = null;
335
652
  let justResized = false;
336
653
 
337
654
  function startResize(ci: number, e: PointerEvent) {
@@ -340,22 +657,24 @@
340
657
  e.stopPropagation();
341
658
  const headCell = (e.currentTarget as HTMLElement).closest('.h') as HTMLElement | null;
342
659
  const startW = headCell ? headCell.getBoundingClientRect().width : layout.info[ci].width;
343
- resize = { key: cols[ci].key, startX: e.clientX, startW };
660
+ resize = { key: cols[ci].key, startX: e.clientX, startW, min: cols[ci].minWidth, max: cols[ci].maxWidth };
344
661
  window.addEventListener('pointermove', onResizeMove);
345
662
  window.addEventListener('pointerup', onResizeUp);
346
663
  }
347
664
  function onResizeMove(e: PointerEvent) {
348
665
  if (!resize) return;
349
- const w = clampWidth(resize.startW + (e.clientX - resize.startX));
666
+ const w = clampWidth(resize.startW + (e.clientX - resize.startX), resize.min, resize.max);
350
667
  widths = { ...widths, [resize.key]: w };
351
668
  }
352
669
  function onResizeUp() {
353
670
  if (!resize) return;
671
+ const { key } = resize;
354
672
  resize = null;
355
673
  justResized = true; // swallow the click that ends this drag (no sort toggle)
356
674
  window.removeEventListener('pointermove', onResizeMove);
357
675
  window.removeEventListener('pointerup', onResizeUp);
358
676
  persistWidths();
677
+ onColumnResize?.(key, widths[key]);
359
678
  }
360
679
  /** Double-click a resize grip to clear the override and restore the default. */
361
680
  function resetWidth(ci: number, e: MouseEvent) {
@@ -410,29 +729,92 @@
410
729
  const base = rows;
411
730
  const allCols = columns;
412
731
  const f = filter.trim().toLowerCase();
732
+ const q = quickText.trim().toLowerCase();
413
733
  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);
734
+ // Active per-column filters: menu-driven structured filters (columnFilters)
735
+ // take precedence; filterRow text inputs (colFilters) fill in the rest as
736
+ // case-insensitive "contains".
737
+ const active: Record<string, ColumnFilter> = { ...activeColumnFilters };
738
+ for (const [k, v] of Object.entries(colFilters)) {
739
+ if (!active[k] && v.trim()) active[k] = { kind: 'text', op: 'contains', q: v };
740
+ }
741
+ const hasColFilters = Object.keys(active).length > 0;
418
742
  return untrack(() => {
419
743
  let r = base;
420
744
  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)));
745
+ if (q) r = r.filter((row) => allCols.some((c) => String(row[c.key] ?? '').toLowerCase().includes(q)));
746
+ if (hasColFilters) {
747
+ r = r.filter((row) => passesFilters(row, active));
748
+ }
749
+ if (s.length > 0) {
750
+ const colOf = (k: string) => allCols.find((c) => c.key === k);
751
+ r = [...r].sort((a, b) => compareBySorts(a, b, s, colOf));
423
752
  }
424
- if (s.length > 0) r = [...r].sort((a, b) => compareBySorts(a, b, s));
425
753
  return r;
426
754
  });
427
755
  });
428
756
 
757
+ // Pagination (in-memory only): slice the view into pages; rows still
758
+ // virtualize within a page. Off when pageSize <= 0.
759
+ const paged = $derived(pageSize > 0 && !source);
760
+ let internalPage = $state(0);
761
+ const currentPage = $derived(page ?? internalPage); // controlled by `page` prop, else internal
762
+ function setPage(p: number): void {
763
+ if (page === undefined) internalPage = p; // uncontrolled: own the state
764
+ onPageChange?.(p); // always notify
765
+ }
766
+ const pageCount = $derived(paged ? Math.max(1, Math.ceil(view.length / pageSize)) : 1);
767
+ // Keep the page in range when the view shrinks (filter/sort changes).
768
+ $effect(() => {
769
+ const max = pageCount - 1;
770
+ untrack(() => {
771
+ if (currentPage > max) setPage(max);
772
+ });
773
+ });
774
+ const pageRows = $derived(
775
+ paged ? view.slice(currentPage * pageSize, currentPage * pageSize + pageSize) : view,
776
+ );
777
+
778
+ const treeData = $derived(!!getChildren && !source);
779
+
780
+ // Drag-to-reorder rows (flat, unsorted, in-memory only). The handle lives in
781
+ // the first cell; the dragged/drop indices are tracked in component state.
782
+ const reorderable = $derived(
783
+ !!onRowReorder && !source && !treeData && groupBy.length === 0 && pageSize <= 0,
784
+ );
785
+ let dragRowVr = $state(-1);
786
+ let dropRowVr = $state(-1);
787
+ function onRowDrop() {
788
+ if (dragRowVr >= 0 && dropRowVr >= 0 && dragRowVr !== dropRowVr) {
789
+ onRowReorder?.(dragRowVr, dropRowVr);
790
+ }
791
+ dragRowVr = -1;
792
+ dropRowVr = -1;
793
+ }
794
+
429
795
  const flat = $derived.by<VisualRow[]>(() => {
430
- const v = view;
796
+ if (treeData && getChildren) {
797
+ const roots = rows;
798
+ expVersion; // track expand/collapse
799
+ return untrack(() =>
800
+ buildTreeRows(roots, getChildren, (r) => expandedRows.has(getRowId(r))),
801
+ );
802
+ }
803
+ const v = pageRows;
431
804
  const gb = groupBy;
432
805
  collapsedVersion;
433
806
  return untrack(() => buildFlatRows(v, gb, collapsed));
434
807
  });
435
808
 
809
+ function goToPage(p: number): void {
810
+ const next = Math.max(0, Math.min(p, pageCount - 1));
811
+ if (next === currentPage) return;
812
+ setPage(next);
813
+ sel.clear();
814
+ editing = null;
815
+ if (viewportEl) viewportEl.scrollTop = 0;
816
+ }
817
+
436
818
  // Header select-all state over the in-memory rows (source mode can't enumerate
437
819
  // unloaded ids, so the header checkbox is disabled there).
438
820
  const selectAll = $derived.by(() => {
@@ -483,22 +865,32 @@
483
865
  // a prefix-sum model (in-memory only — source mode can't know unloaded heights).
484
866
  const baseH = $derived(typeof rowHeight === 'number' && rowHeight > 0 ? rowHeight : ROW_H);
485
867
  const variable = $derived(typeof rowHeight === 'function' && !source);
868
+ // Use a prefix-sum height model when row heights vary: a `rowHeight` function,
869
+ // or master-detail expansion (expanded rows are taller by `detailHeight`).
870
+ const useHeights = $derived(variable || expandable);
486
871
  const heights = $derived.by<number[] | null>(() => {
487
- if (!variable) return null;
488
- const fn = rowHeight as (row: GridRow, index: number) => number;
872
+ if (!useHeights) return null;
873
+ const fn = variable ? (rowHeight as (row: GridRow, index: number) => number) : null;
874
+ expVersion; // track expansion changes
489
875
  const arr = new Array<number>(flat.length);
490
876
  let di = 0;
491
877
  for (let i = 0; i < flat.length; i++) {
492
878
  const it = flat[i];
493
- arr[i] = it.kind === 'data' ? Math.max(1, fn(it.row, di++)) : baseH;
879
+ if (it.kind !== 'data') {
880
+ arr[i] = baseH;
881
+ continue;
882
+ }
883
+ let h = fn ? Math.max(1, fn(it.row, di++)) : baseH;
884
+ if (expandable && expandedRows.has(getRowId(it.row))) h += detailHeight;
885
+ arr[i] = h;
494
886
  }
495
887
  return arr;
496
888
  });
497
- const hm = $derived(variable && heights ? variableHeights(heights) : uniformHeights(rowCount, baseH));
889
+ const hm = $derived(useHeights && heights ? variableHeights(heights) : uniformHeights(rowCount, baseH));
498
890
 
499
891
  const total = $derived(hm.total);
500
892
  const rowWidthStyle = $derived(
501
- pinned ? `width:${layout.totalWidth + selOffset * SEL_W}px;right:auto;` : '',
893
+ pinned ? `width:${layout.totalWidth + leadPx}px;right:auto;` : '',
502
894
  );
503
895
  const visibleCount = $derived(Math.ceil(height / baseH) + OVERSCAN * 2);
504
896
  const start = $derived(Math.max(0, hm.indexAt(scrollTop) - OVERSCAN));
@@ -510,7 +902,7 @@
510
902
 
511
903
  type RenderItem =
512
904
  | { vr: number; kind: 'group'; group: GroupNode }
513
- | { vr: number; kind: 'data'; row: GridRow }
905
+ | { vr: number; kind: 'data'; row: GridRow; depth?: number; hasChildren?: boolean }
514
906
  | { vr: number; kind: 'skeleton' };
515
907
 
516
908
  const renderItems = $derived.by<RenderItem[]>(() => {
@@ -526,7 +918,7 @@
526
918
  const item = flat[vr];
527
919
  if (!item) continue;
528
920
  if (item.kind === 'group') out.push({ vr, kind: 'group', group: item.group });
529
- else out.push({ vr, kind: 'data', row: item.row });
921
+ else out.push({ vr, kind: 'data', row: item.row, depth: item.depth, hasChildren: item.hasChildren });
530
922
  }
531
923
  }
532
924
  return out;
@@ -543,7 +935,8 @@
543
935
  const range = { start, end: start + visibleCount };
544
936
  const s = sorts;
545
937
  const f = filter;
546
- void ctrl.fetch(range, s, f);
938
+ const cf = activeColumnFilters;
939
+ void ctrl.fetch(range, s, f, cf);
547
940
  });
548
941
 
549
942
  function dataAt(r: number): GridRow | null {
@@ -595,7 +988,171 @@
595
988
  }
596
989
 
597
990
  function onCellEnter(r: number, c: number) {
598
- if (dragging) sel.extendTo(r, c);
991
+ if (filling) fillTo = { r, c };
992
+ else if (dragging) sel.extendTo(r, c);
993
+ }
994
+
995
+ // Fill handle: start dragging from the selection's bottom-right corner.
996
+ function onFillStart(): void {
997
+ const b = sel.bounds;
998
+ if (!b) return;
999
+ fillSource = { ...b };
1000
+ fillTo = { r: b.r1, c: b.c1 };
1001
+ filling = true;
1002
+ }
1003
+ // The rectangle the fill drag currently covers (extends down/right only).
1004
+ const fillRange = $derived.by(() => {
1005
+ if (!filling || !fillSource || !fillTo) return null;
1006
+ return {
1007
+ r0: fillSource.r0,
1008
+ c0: fillSource.c0,
1009
+ r1: Math.max(fillSource.r1, fillTo.r),
1010
+ c1: Math.max(fillSource.c1, fillTo.c),
1011
+ };
1012
+ });
1013
+ function inFillPreview(r: number, c: number): boolean {
1014
+ const fr = fillRange;
1015
+ return !!fr && r >= fr.r0 && r <= fr.r1 && c >= fr.c0 && c <= fr.c1 && !sel.contains(r, c);
1016
+ }
1017
+ // Commit the fill: tile the source block's values across the extended range.
1018
+ function commitFill(): void {
1019
+ if (!filling) return;
1020
+ filling = false;
1021
+ const b = fillSource;
1022
+ const ft = fillTo;
1023
+ fillTo = null;
1024
+ fillSource = null;
1025
+ if (!b || !ft) return;
1026
+ const r1 = Math.max(b.r1, ft.r);
1027
+ const c1 = Math.max(b.c1, ft.c);
1028
+ if (r1 === b.r1 && c1 === b.c1) return; // no extension
1029
+ const srcRows = b.r1 - b.r0 + 1;
1030
+ const srcCols = b.c1 - b.c0 + 1;
1031
+ batch(() => {
1032
+ for (let r = b.r0; r <= r1; r++) {
1033
+ for (let c = b.c0; c <= c1; c++) {
1034
+ if (r <= b.r1 && c <= b.c1) continue; // skip the source block
1035
+ const srcRow = dataAt(b.r0 + ((r - b.r0) % srcRows));
1036
+ if (!srcRow) continue;
1037
+ writeCell(r, c, String(srcRow[cols[b.c0 + ((c - b.c0) % srcCols)].key] ?? ''));
1038
+ }
1039
+ }
1040
+ });
1041
+ sel.anchor = { r: b.r0, c: b.c0 };
1042
+ sel.focus = { r: r1, c: c1 };
1043
+ }
1044
+
1045
+ // Right-click row menu (floating).
1046
+ let menu = $state<{ x: number; y: number; items: Array<{ label: string; onSelect: () => void }> } | null>(null);
1047
+ function openRowMenu(row: GridRow, e: MouseEvent) {
1048
+ if (!rowMenu) return;
1049
+ const items = rowMenu(row);
1050
+ if (items.length === 0) return;
1051
+ e.preventDefault();
1052
+ menu = { x: e.clientX, y: e.clientY, items };
1053
+ }
1054
+ $effect(() => {
1055
+ if (!menu) return;
1056
+ const close = () => (menu = null);
1057
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && (menu = null);
1058
+ window.addEventListener('pointerdown', close);
1059
+ window.addEventListener('keydown', onKey);
1060
+ window.addEventListener('blur', close);
1061
+ return () => {
1062
+ window.removeEventListener('pointerdown', close);
1063
+ window.removeEventListener('keydown', onKey);
1064
+ window.removeEventListener('blur', close);
1065
+ };
1066
+ });
1067
+
1068
+ // Header filter menu (v0.3), lazy-loaded on first open to keep the core lean.
1069
+ let FilterMenuComp = $state<typeof import('./FilterMenu.svelte').default | null>(null);
1070
+ let filterUi = $state<{
1071
+ key: string;
1072
+ kind: FilterKind;
1073
+ header: string;
1074
+ values: string[];
1075
+ x: number;
1076
+ y: number;
1077
+ } | null>(null);
1078
+ function filterKindFor(col: ColumnDef): FilterKind {
1079
+ return typeof col.filter === 'string' ? col.filter : defaultFilterKind(col);
1080
+ }
1081
+ async function openFilterMenu(col: ColumnDef, e: Event) {
1082
+ e.stopPropagation();
1083
+ // Read the anchor rect now — after the await, the event's currentTarget is null.
1084
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
1085
+ let kind = filterKindFor(col);
1086
+ // A set filter needs distinct values; in source mode they can't be
1087
+ // enumerated, so fall back to the column's typed filter.
1088
+ if (source && kind === 'set') kind = defaultFilterKind(col);
1089
+ const values = !source && kind === 'set' ? distinctValues(rows, col.key) : [];
1090
+ if (!FilterMenuComp) FilterMenuComp = (await import('./FilterMenu.svelte')).default;
1091
+ filterUi = { key: col.key, kind, header: col.header, values, x: rect.left, y: rect.bottom + 2 };
1092
+ }
1093
+ function applyColumnFilter(key: string, f: ColumnFilter | null): void {
1094
+ const next = { ...activeColumnFilters };
1095
+ if (f) next[key] = f;
1096
+ else delete next[key];
1097
+ setColumnFilters(next);
1098
+ filterUi = null;
1099
+ }
1100
+ $effect(() => {
1101
+ if (!filterUi) return;
1102
+ const close = () => (filterUi = null);
1103
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && (filterUi = null);
1104
+ window.addEventListener('pointerdown', close);
1105
+ window.addEventListener('keydown', onKey);
1106
+ window.addEventListener('blur', close);
1107
+ return () => {
1108
+ window.removeEventListener('pointerdown', close);
1109
+ window.removeEventListener('keydown', onKey);
1110
+ window.removeEventListener('blur', close);
1111
+ };
1112
+ });
1113
+
1114
+ // Autosize a column to its content (a character-count heuristic — no DOM
1115
+ // measurement, so it's cheap and deterministic). Sets the same width override
1116
+ // as a manual resize.
1117
+ function autosizeColumn(col: ColumnDef): void {
1118
+ const CHAR_PX = 7.5;
1119
+ const PAD = 32; // cell padding + room for the header icons
1120
+ let maxLen = col.header.length;
1121
+ if (!source) {
1122
+ for (const row of view.slice(0, 500)) {
1123
+ const s = formatCell(col, row[col.key], row);
1124
+ if (s.length > maxLen) maxLen = s.length;
1125
+ }
1126
+ }
1127
+ const w = clampWidth(Math.round(maxLen * CHAR_PX) + PAD, col.minWidth, col.maxWidth);
1128
+ widths = { ...widths, [col.key]: w };
1129
+ persistWidths();
1130
+ onColumnResize?.(col.key, w);
1131
+ }
1132
+
1133
+ // Column header menu (⋮): sort / pin / autosize / hide. Reuses the floating
1134
+ // RowMenu (a light action list — no lazy chunk, unlike the filter menu).
1135
+ function columnMenuItems(col: ColumnDef): Array<{ label: string; onSelect: () => void }> {
1136
+ const items: Array<{ label: string; onSelect: () => void }> = [];
1137
+ if (isSortable(col)) {
1138
+ items.push({ label: 'Sort ascending', onSelect: () => setSorts([{ key: col.key, dir: 'asc' }]) });
1139
+ items.push({ label: 'Sort descending', onSelect: () => setSorts([{ key: col.key, dir: 'desc' }]) });
1140
+ if (sortInfo(col.key)) {
1141
+ items.push({ label: 'Clear sort', onSelect: () => setSorts(sorts.filter((s) => s.key !== col.key)) });
1142
+ }
1143
+ }
1144
+ const side = pinSideOf(col);
1145
+ if (side !== 'left') items.push({ label: 'Pin left', onSelect: () => setPinOverride(col.key, 'left') });
1146
+ if (side !== 'right') items.push({ label: 'Pin right', onSelect: () => setPinOverride(col.key, 'right') });
1147
+ if (side) items.push({ label: 'Unpin', onSelect: () => setPinOverride(col.key, false) });
1148
+ if (isResizable(col, resizable)) items.push({ label: 'Autosize', onSelect: () => autosizeColumn(col) });
1149
+ items.push({ label: 'Hide column', onSelect: () => hideColumn(col.key) });
1150
+ return items;
1151
+ }
1152
+ function openColumnMenu(col: ColumnDef, e: Event) {
1153
+ e.stopPropagation();
1154
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
1155
+ menu = { x: rect.left, y: rect.bottom + 2, items: columnMenuItems(col) };
599
1156
  }
600
1157
 
601
1158
  function onCellClicked(r: number, c: number, e: MouseEvent) {
@@ -636,7 +1193,7 @@
636
1193
  const cells: string[] = [];
637
1194
  for (let c = b.c0; c <= cEnd; c++) {
638
1195
  const col = cols[c];
639
- cells.push(col.type === 'sparkline' || col.type === 'custom' ? '' : formatCell(col, row[col.key]));
1196
+ cells.push(col.type === 'sparkline' || col.type === 'custom' ? '' : formatCell(col, row[col.key], row));
640
1197
  }
641
1198
  lines.push(cells.join('\t'));
642
1199
  }
@@ -667,18 +1224,20 @@
667
1224
  const c0 = anchor.c0;
668
1225
  const rSpan = single ? anchor.r1 - anchor.r0 + 1 : grid.length;
669
1226
  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++;
1227
+ batch(() => {
1228
+ for (let dr = 0; dr < rSpan; dr++) {
1229
+ const r = r0 + dr;
1230
+ if (r > rowCount - 1) break;
1231
+ const srcRow = single ? grid[0] : grid[dr];
1232
+ const cSpan = single ? anchor.c1 - anchor.c0 + 1 : srcRow.length;
1233
+ for (let dc = 0; dc < cSpan; dc++) {
1234
+ const c = c0 + dc;
1235
+ if (c > cols.length - 1) break;
1236
+ const raw = single ? grid[0][0] : (srcRow[dc] ?? '');
1237
+ if (writeCell(r, c, raw)) wrote++;
1238
+ }
680
1239
  }
681
- }
1240
+ });
682
1241
  // Surface the pasted region as the new selection so it's visible.
683
1242
  if (wrote > 0) {
684
1243
  const rEnd = Math.min(r0 + rSpan - 1, rowCount - 1);
@@ -708,6 +1267,42 @@
708
1267
  }
709
1268
  }
710
1269
  }
1270
+ // Space toggles selection of the focused row (when the checkbox column is on),
1271
+ // so keyboard users can tick rows without reaching for the mouse.
1272
+ if (e.key === ' ' && rowSelection && sel.focus && !editing) {
1273
+ const row = dataAt(sel.focus.r);
1274
+ if (row) {
1275
+ e.preventDefault();
1276
+ toggleRow(getRowId(row));
1277
+ return;
1278
+ }
1279
+ }
1280
+ // Type-to-edit (Excel-style): a printable key on a focused editable text/number
1281
+ // cell opens the editor seeded with that character. Select-column editors keep
1282
+ // their Enter-to-open behavior.
1283
+ if (!mod && !e.altKey && e.key.length === 1 && e.key !== ' ' && sel.focus && !editing) {
1284
+ const f = sel.focus;
1285
+ const col = cols[f.c];
1286
+ if (isEditable(col) && col.type !== 'date' && !(col.options && col.options.length) && dataAt(f.r)) {
1287
+ e.preventDefault();
1288
+ startEdit(f.r, f.c, e.key);
1289
+ return;
1290
+ }
1291
+ }
1292
+ if (mod && e.key.toLowerCase() === 'z' && !e.shiftKey) {
1293
+ if (undoStack.length) {
1294
+ e.preventDefault();
1295
+ undo();
1296
+ return;
1297
+ }
1298
+ }
1299
+ if (mod && (e.key.toLowerCase() === 'y' || (e.shiftKey && e.key.toLowerCase() === 'z'))) {
1300
+ if (redoStack.length) {
1301
+ e.preventDefault();
1302
+ redo();
1303
+ return;
1304
+ }
1305
+ }
711
1306
  if (mod && e.key.toLowerCase() === 'a') {
712
1307
  e.preventDefault();
713
1308
  sel.selectAll(rowCount, cols.length);
@@ -727,6 +1322,43 @@
727
1322
  sel.clear();
728
1323
  return;
729
1324
  }
1325
+ // Open the row menu from the keyboard (ContextMenu key / Shift+F10).
1326
+ if (rowMenu && sel.focus && (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'))) {
1327
+ const row = dataAt(sel.focus.r);
1328
+ const items = row ? rowMenu(row) : [];
1329
+ if (items.length > 0) {
1330
+ e.preventDefault();
1331
+ const rect = document.getElementById(activeId ?? '')?.getBoundingClientRect();
1332
+ menu = { x: rect?.left ?? 0, y: rect?.bottom ?? 0, items };
1333
+ return;
1334
+ }
1335
+ }
1336
+ // Open the column menu from the keyboard (Alt+ArrowDown) at the focused column.
1337
+ if (columnMenu && sel.focus && e.altKey && e.key === 'ArrowDown') {
1338
+ e.preventDefault();
1339
+ const rect = document.getElementById(activeId ?? '')?.getBoundingClientRect();
1340
+ menu = { x: rect?.left ?? 0, y: rect?.bottom ?? 0, items: columnMenuItems(cols[sel.focus.c]) };
1341
+ return;
1342
+ }
1343
+ // Tree nav: ArrowRight expands a collapsed node, ArrowLeft collapses an
1344
+ // expanded one (treegrid pattern); otherwise arrows move normally.
1345
+ if (treeData && sel.focus && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) {
1346
+ const item = flat[sel.focus.r];
1347
+ if (item?.kind === 'data' && item.hasChildren) {
1348
+ const id = getRowId(item.row);
1349
+ const open = expandedRows.has(id);
1350
+ if (e.key === 'ArrowRight' && !open) {
1351
+ e.preventDefault();
1352
+ toggleExpand(id);
1353
+ return;
1354
+ }
1355
+ if (e.key === 'ArrowLeft' && open) {
1356
+ e.preventDefault();
1357
+ toggleExpand(id);
1358
+ return;
1359
+ }
1360
+ }
1361
+ }
730
1362
  // Home/End (row, or whole grid with Ctrl/⌘); PageUp/PageDown by a viewport page.
731
1363
  const f = sel.focus;
732
1364
  if (f && (e.key === 'Home' || e.key === 'End' || e.key === 'PageUp' || e.key === 'PageDown')) {
@@ -753,7 +1385,10 @@
753
1385
  }
754
1386
 
755
1387
  $effect(() => {
756
- const up = () => (dragging = false);
1388
+ const up = () => {
1389
+ dragging = false;
1390
+ if (filling) commitFill();
1391
+ };
757
1392
  window.addEventListener('pointerup', up);
758
1393
  return () => window.removeEventListener('pointerup', up);
759
1394
  });
@@ -777,16 +1412,34 @@
777
1412
  role="grid"
778
1413
  tabindex="0"
779
1414
  id={gid}
1415
+ aria-label={ariaLabel}
780
1416
  aria-rowcount={rowCount + 1}
781
- aria-colcount={cols.length + selOffset}
1417
+ aria-colcount={cols.length + leadCols}
782
1418
  aria-multiselectable="true"
783
1419
  aria-activedescendant={activeId}
784
1420
  style={themeStyle}
785
1421
  bind:this={gridEl}
786
1422
  onkeydown={onKeydown}
787
1423
  >
1424
+ {#if quickFilter || columnsPanel}
1425
+ <div class="bo-toolbar">
1426
+ {#if quickFilter}
1427
+ <input
1428
+ class="bo-quickfilter"
1429
+ type="search"
1430
+ placeholder="Search…"
1431
+ aria-label="Quick filter"
1432
+ bind:value={quickText}
1433
+ />
1434
+ {/if}
1435
+ {#if columnsPanel}
1436
+ <button class="bo-cols-toggle" type="button" onclick={openToolPanel}>⊟ Columns</button>
1437
+ {/if}
1438
+ </div>
1439
+ {/if}
788
1440
  {#if headerGroups}
789
1441
  <div class="head-groups" aria-hidden="true" bind:this={groupHeadEl} style={pinned ? 'overflow:hidden;' : ''}>
1442
+ {#if expandable}<span class="expandcell" style={expandCellStyle(true)}></span>{/if}
790
1443
  {#if rowSelection}<span class="selcell" style={selCellStyle(true)}></span>{/if}
791
1444
  {#each headerGroups as g, gi (gi)}
792
1445
  <span class="hg" class:empty={!g.label} style="flex:0 0 {g.width}px;width:{g.width}px;">{g.label}</span>
@@ -794,8 +1447,11 @@
794
1447
  </div>
795
1448
  {/if}
796
1449
  <div class="head" role="row" aria-rowindex={1} bind:this={headEl} style={pinned ? 'overflow:hidden;' : ''}>
1450
+ {#if expandable}
1451
+ <span class="expandcell selhead" role="columnheader" aria-colindex={1} style={expandCellStyle(true)}></span>
1452
+ {/if}
797
1453
  {#if rowSelection}
798
- <span class="selcell selhead" role="columnheader" aria-colindex={1} style={selCellStyle(true)}>
1454
+ <span class="selcell selhead" role="columnheader" aria-colindex={1 + expOffset} style={selCellStyle(true)}>
799
1455
  <input
800
1456
  type="checkbox"
801
1457
  class="rowcheck"
@@ -810,7 +1466,7 @@
810
1466
  {/if}
811
1467
  {#each cols as col, ci (ci)}
812
1468
  <button
813
- class="h"
1469
+ class="h {col.headerClass ?? ''}"
814
1470
  class:right={isNumeric(col) || col.align === 'right'}
815
1471
  class:sortable={isSortable(col)}
816
1472
  class:dragging={ci === dragSrc}
@@ -818,7 +1474,7 @@
818
1474
  style={headStyle(ci)}
819
1475
  type="button"
820
1476
  role="columnheader"
821
- aria-colindex={ci + 1 + selOffset}
1477
+ aria-colindex={ci + 1 + leadCols}
822
1478
  draggable="true"
823
1479
  aria-sort={isSortable(col) && sortInfo(col.key)
824
1480
  ? sortInfo(col.key)?.dir === 'asc'
@@ -855,6 +1511,40 @@
855
1511
  {si?.dir === 'asc' ? '▲' : '▼'}{#if sorts.length > 1}<span class="ord">{si?.pos}</span>{/if}
856
1512
  </span>
857
1513
  {/if}
1514
+ {#if filterMenu && col.type !== 'sparkline' && col.filter !== false}
1515
+ <span
1516
+ class="funnel"
1517
+ class:on={isFilterActive(activeColumnFilters[col.key])}
1518
+ role="button"
1519
+ tabindex="-1"
1520
+ aria-label="Filter {col.header}"
1521
+ title="Filter {col.header}"
1522
+ onclick={(e) => openFilterMenu(col, e)}
1523
+ onkeydown={(e) => {
1524
+ if (e.key === 'Enter' || e.key === ' ') openFilterMenu(col, e);
1525
+ }}
1526
+ ondragstart={(e) => e.preventDefault()}
1527
+ draggable="false"
1528
+ >
1529
+ <svg viewBox="0 0 10 10" width="9" height="9" aria-hidden="true">
1530
+ <path d="M0 1h10L6 6v3L4 8V6z" fill="currentColor" />
1531
+ </svg>
1532
+ </span>
1533
+ {/if}
1534
+ {#if columnMenu}
1535
+ <span
1536
+ class="hmenu"
1537
+ role="button"
1538
+ tabindex="-1"
1539
+ aria-label="{col.header} menu"
1540
+ title="{col.header} menu"
1541
+ onclick={(e) => openColumnMenu(col, e)}
1542
+ onkeydown={(e) => {
1543
+ if (e.key === 'Enter' || e.key === ' ') openColumnMenu(col, e);
1544
+ }}
1545
+ ondragstart={(e) => e.preventDefault()}
1546
+ draggable="false">⋮</span>
1547
+ {/if}
858
1548
  {#if isResizable(col, resizable)}
859
1549
  <span
860
1550
  class="grip"
@@ -873,6 +1563,7 @@
873
1563
 
874
1564
  {#if filterRow && !source}
875
1565
  <div class="filter-row" role="row" bind:this={filterRowEl} style={pinned ? 'overflow:hidden;' : ''}>
1566
+ {#if expandable}<span class="expandcell" style={expandCellStyle(false)}></span>{/if}
876
1567
  {#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
877
1568
  {#each cols as col, ci (ci)}
878
1569
  <span class="fr-cell" style={cellWidthStyle(ci)}>
@@ -901,6 +1592,7 @@
901
1592
  <div class="pinned-top">
902
1593
  {#each pinnedRows as prow, pi (getRowId(prow))}
903
1594
  <div class="row pinrow {rowClass?.(prow) ?? ''}" role="row" aria-hidden="true" style="height:{baseH}px;{rowWidthStyle}">
1595
+ {#if expandable}<span class="expandcell" style={expandCellStyle(false)}></span>{/if}
904
1596
  {#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
905
1597
  {#each cols as col, ci (ci)}
906
1598
  <Cell
@@ -908,11 +1600,12 @@
908
1600
  row={prow}
909
1601
  r={-1 - pi}
910
1602
  c={ci}
911
- colIndex={ci + 1 + selOffset}
1603
+ colIndex={ci + 1 + leadCols}
912
1604
  cellId={`${gid}-pin${pi}-c${ci}`}
913
1605
  cellSnippet={cell}
914
1606
  pinned={pinned && layout.info[ci].pinned}
915
- pinLeft={layout.info[ci].left + selOffset * SEL_W}
1607
+ pinSide={layout.info[ci].side ?? 'left'}
1608
+ pinOffset={layout.info[ci].side === 'right' ? layout.info[ci].right : layout.info[ci].left + leadPx}
916
1609
  width={pinned ? layout.info[ci].width : undefined}
917
1610
  />
918
1611
  {/each}
@@ -921,7 +1614,13 @@
921
1614
  </div>
922
1615
  {/if}
923
1616
  {#if rowCount === 0 && !controller?.loading}
924
- <div class="empty">No matching rows</div>
1617
+ <div class="empty">{emptyMessage}</div>
1618
+ {/if}
1619
+ {#if loading}
1620
+ <div class="loading-overlay" aria-busy="true" aria-live="polite">
1621
+ <span class="spinner" aria-hidden="true"></span>
1622
+ <span class="loading-label">Loading…</span>
1623
+ </div>
925
1624
  {/if}
926
1625
  {#if stickyGroups.length > 0}
927
1626
  <div class="sticky">
@@ -932,15 +1631,17 @@
932
1631
  {/each}
933
1632
  </div>
934
1633
  {/if}
935
- <div class="spacer" style="height:{total}px;{pinned ? `width:${layout.totalWidth + selOffset * SEL_W}px;` : ''}">
1634
+ <div class="spacer" style="height:{total}px;{pinned ? `width:${layout.totalWidth + leadPx}px;` : ''}">
936
1635
  {#each renderItems as item (item.vr)}
937
1636
  {#if item.kind === 'group'}
938
1637
  <div class="grouprow" style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}">
1638
+ {#if expandable}<span class="expandcell" aria-hidden="true" style={expandCellStyle(false)}></span>{/if}
939
1639
  {#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
940
1640
  <GroupRow group={item.group} columns={cols} onToggle={toggleGroup} rowIndex={item.vr + 2} />
941
1641
  </div>
942
1642
  {:else if item.kind === 'skeleton'}
943
1643
  <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}">
1644
+ {#if expandable}<span class="expandcell" style={expandCellStyle(false)}></span>{/if}
944
1645
  {#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
945
1646
  {#each cols as col, ci (ci)}
946
1647
  <span class="c" style={cellWidthStyle(ci)}><span class="skelbar"></span></span>
@@ -949,7 +1650,24 @@
949
1650
  {:else}
950
1651
  <!-- Row activation is keyboard-accessible at the grid level: Enter on the focused cell fires onRowClick (focus is via aria-activedescendant). -->
951
1652
  <!-- 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)}>
1653
+ <div class="row {rowClass?.(item.row) ?? ''}" class:alt={item.vr % 2 === 1} class:rowsel={rowSelection && isRowSelected(getRowId(item.row))} class:clickable={!!onRowClick} class:droptarget={reorderable && dropRowVr === item.vr && dragRowVr !== item.vr} role="row" tabindex="-1" aria-rowindex={item.vr + 2} aria-selected={rowSelection ? isRowSelected(getRowId(item.row)) : undefined} aria-level={treeData ? (item.depth ?? 0) + 1 : undefined} aria-expanded={treeData && item.hasChildren ? isExpanded(getRowId(item.row)) : undefined} style="top:{hm.offsetOf(item.vr)}px;height:{expandable ? baseH : hm.heightOf(item.vr)}px;{rowWidthStyle}" onclick={(e) => onRowClick?.(item.row, e)} oncontextmenu={(e) => openRowMenu(item.row, e)} ondragover={reorderable ? (e) => { if (dragRowVr < 0) return; e.preventDefault(); dropRowVr = item.vr; } : undefined} ondrop={reorderable ? (e) => { e.preventDefault(); onRowDrop(); } : undefined}>
1654
+ {#if expandable}
1655
+ <span class="expandcell" style={expandCellStyle(false)}>
1656
+ <button
1657
+ class="expand-toggle"
1658
+ type="button"
1659
+ aria-expanded={isExpanded(getRowId(item.row))}
1660
+ aria-label="Toggle detail"
1661
+ onpointerdown={(e) => e.stopPropagation()}
1662
+ onclick={(e) => {
1663
+ e.stopPropagation();
1664
+ toggleExpand(getRowId(item.row));
1665
+ }}
1666
+ >
1667
+ {isExpanded(getRowId(item.row)) ? '▾' : '▸'}
1668
+ </button>
1669
+ </span>
1670
+ {/if}
953
1671
  {#if rowSelection}
954
1672
  <span class="selcell" style={selCellStyle(false)}>
955
1673
  <input
@@ -969,30 +1687,55 @@
969
1687
  row={item.row}
970
1688
  r={item.vr}
971
1689
  c={ci}
972
- colIndex={ci + 1 + selOffset}
1690
+ colIndex={ci + 1 + leadCols}
973
1691
  cellId={`${gid}-r${item.vr}-c${ci}`}
974
1692
  cellSnippet={cell}
975
1693
  selected={sel.contains(item.vr, ci)}
976
1694
  focused={sel.isFocus(item.vr, ci)}
977
1695
  pinned={pinned && layout.info[ci].pinned}
978
- pinLeft={layout.info[ci].left + selOffset * SEL_W}
1696
+ pinSide={layout.info[ci].side ?? 'left'}
1697
+ pinOffset={layout.info[ci].side === 'right' ? layout.info[ci].right : layout.info[ci].left + leadPx}
979
1698
  width={pinned ? layout.info[ci].width : undefined}
980
1699
  alt={item.vr % 2 === 1}
981
1700
  editing={editing?.r === item.vr && editing?.c === ci}
1701
+ seed={editing?.r === item.vr && editing?.c === ci ? editSeed : null}
1702
+ fillCorner={fillHandle && !!sel.bounds && item.vr === sel.bounds.r1 && ci === sel.bounds.c1}
1703
+ fillpreview={inFillPreview(item.vr, ci)}
1704
+ {onFillStart}
1705
+ tree={treeData && ci === 0
1706
+ ? {
1707
+ depth: item.depth ?? 0,
1708
+ hasChildren: item.hasChildren ?? false,
1709
+ expanded: isExpanded(getRowId(item.row)),
1710
+ onToggle: () => toggleExpand(getRowId(item.row)),
1711
+ }
1712
+ : undefined}
1713
+ dragHandle={reorderable && ci === 0
1714
+ ? { onStart: () => (dragRowVr = item.vr), onEnd: () => (dragRowVr = -1) }
1715
+ : undefined}
982
1716
  {onCellDown}
983
1717
  {onCellEnter}
984
1718
  onCellClick={onCellClick ? onCellClicked : undefined}
985
1719
  onCellDblClick={startEdit}
986
1720
  onEditCommit={(raw) => commitEdit(item.vr, ci, raw)}
987
- onEditCancel={() => (editing = null)}
1721
+ onEditCancel={() => {
1722
+ editing = null;
1723
+ editSeed = null;
1724
+ }}
988
1725
  />
989
1726
  {/each}
990
1727
  </div>
1728
+ {#if expandable && detail && isExpanded(getRowId(item.row))}
1729
+ <div class="row-detail" style="top:{hm.offsetOf(item.vr) + baseH}px;height:{detailHeight}px;{rowWidthStyle}">
1730
+ {@render detail({ row: item.row })}
1731
+ </div>
1732
+ {/if}
991
1733
  {/if}
992
1734
  {/each}
993
1735
  </div>
994
1736
  {#if footerCells}
995
- <div class="footer" role="row" style={pinned ? `width:${layout.totalWidth + selOffset * SEL_W}px;` : ''}>
1737
+ <div class="footer" role="row" style={pinned ? `width:${layout.totalWidth + leadPx}px;` : ''}>
1738
+ {#if expandable}<span class="expandcell" aria-hidden="true" style={expandCellStyle(false)}></span>{/if}
996
1739
  {#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
997
1740
  {#each cols as col, ci (ci)}
998
1741
  <span class="fcell" class:right={isNumeric(col)} style={cellWidthStyle(ci)}>
@@ -1004,6 +1747,42 @@
1004
1747
  </div>
1005
1748
 
1006
1749
  <AggregationBar result={agg} kinds={aggregations} />
1750
+
1751
+ {#if paged}
1752
+ <Pager page={currentPage} {pageCount} total={view.length} onGoto={goToPage} />
1753
+ {/if}
1754
+
1755
+ {#if menu}
1756
+ <RowMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => (menu = null)} />
1757
+ {/if}
1758
+
1759
+ {#if filterUi && FilterMenuComp}
1760
+ {@const Menu = FilterMenuComp}
1761
+ {@const key = filterUi.key}
1762
+ <Menu
1763
+ kind={filterUi.kind}
1764
+ header={filterUi.header}
1765
+ filter={activeColumnFilters[key] ?? null}
1766
+ values={filterUi.values}
1767
+ x={filterUi.x}
1768
+ y={filterUi.y}
1769
+ onApply={(f) => applyColumnFilter(key, f)}
1770
+ onClose={() => (filterUi = null)}
1771
+ />
1772
+ {/if}
1773
+
1774
+ {#if panelXY && ToolPanelComp}
1775
+ {@const Panel = ToolPanelComp}
1776
+ <Panel
1777
+ columns={ordered.map((c) => ({ key: c.key, header: c.header }))}
1778
+ hidden={effectiveHidden}
1779
+ x={panelXY.x}
1780
+ y={panelXY.y}
1781
+ onToggle={toggleColumnVisible}
1782
+ onShowAll={showAllColumns}
1783
+ onClose={() => (panelXY = null)}
1784
+ />
1785
+ {/if}
1007
1786
  </div>
1008
1787
 
1009
1788
  <style>
@@ -1038,6 +1817,41 @@
1038
1817
  .grid:focus-visible {
1039
1818
  border-color: var(--bo-sel-border);
1040
1819
  }
1820
+ /* Built-in quick-filter toolbar (opt-in via `quickFilter`). */
1821
+ .bo-toolbar {
1822
+ display: flex;
1823
+ padding: 6px;
1824
+ border-bottom: 0.5px solid var(--bo-border);
1825
+ background: var(--bo-header-bg);
1826
+ }
1827
+ .bo-quickfilter {
1828
+ width: 100%;
1829
+ max-width: 260px;
1830
+ padding: 5px 9px;
1831
+ font: inherit;
1832
+ font-size: 12px;
1833
+ color: var(--bo-text);
1834
+ background: var(--bo-bg);
1835
+ border: 0.5px solid var(--bo-border);
1836
+ border-radius: 6px;
1837
+ }
1838
+ .bo-quickfilter::placeholder {
1839
+ color: var(--bo-text-dim);
1840
+ }
1841
+ .bo-cols-toggle {
1842
+ margin-left: auto;
1843
+ padding: 5px 11px;
1844
+ font: inherit;
1845
+ font-size: 12px;
1846
+ color: var(--bo-text);
1847
+ background: var(--bo-bg);
1848
+ border: 0.5px solid var(--bo-border);
1849
+ border-radius: 6px;
1850
+ cursor: pointer;
1851
+ }
1852
+ .bo-cols-toggle:hover {
1853
+ border-color: var(--bo-text-dim);
1854
+ }
1041
1855
 
1042
1856
  /* Spanning header groups (row above the column headers). */
1043
1857
  .head-groups {
@@ -1152,6 +1966,40 @@
1152
1966
  color: var(--bo-text-dim);
1153
1967
  font-variant-numeric: tabular-nums;
1154
1968
  }
1969
+ /* Header filter funnel: a click target that opens the (lazy) filter menu. */
1970
+ .h .funnel {
1971
+ display: inline-flex;
1972
+ align-items: center;
1973
+ margin-left: 4px;
1974
+ color: var(--bo-text-dim);
1975
+ opacity: 0.55;
1976
+ cursor: pointer;
1977
+ transition: opacity 120ms, color 120ms;
1978
+ }
1979
+ .h .funnel:hover {
1980
+ opacity: 1;
1981
+ color: var(--bo-text);
1982
+ }
1983
+ .h .funnel.on {
1984
+ opacity: 1;
1985
+ color: var(--bo-up);
1986
+ }
1987
+ /* Header column-menu (⋮) trigger. */
1988
+ .h .hmenu {
1989
+ display: inline-flex;
1990
+ align-items: center;
1991
+ margin-left: 4px;
1992
+ font-size: 14px;
1993
+ line-height: 1;
1994
+ color: var(--bo-text-dim);
1995
+ opacity: 0.55;
1996
+ cursor: pointer;
1997
+ transition: opacity 120ms, color 120ms;
1998
+ }
1999
+ .h .hmenu:hover {
2000
+ opacity: 1;
2001
+ color: var(--bo-text);
2002
+ }
1155
2003
 
1156
2004
  /* Per-column filter input row, under the header. */
1157
2005
  .filter-row {
@@ -1199,6 +2047,40 @@
1199
2047
  color: var(--bo-text-dim);
1200
2048
  font-size: 13px;
1201
2049
  }
2050
+ .loading-overlay {
2051
+ position: sticky;
2052
+ top: 0;
2053
+ left: 0;
2054
+ z-index: 7;
2055
+ display: flex;
2056
+ align-items: center;
2057
+ justify-content: center;
2058
+ gap: 10px;
2059
+ height: 100%;
2060
+ margin-bottom: -100%; /* overlay without consuming scroll height */
2061
+ color: var(--bo-text-dim);
2062
+ font-size: 13px;
2063
+ background: color-mix(in srgb, var(--bo-bg) 70%, transparent);
2064
+ backdrop-filter: blur(1px);
2065
+ }
2066
+ .spinner {
2067
+ width: 18px;
2068
+ height: 18px;
2069
+ border: 2px solid var(--bo-border);
2070
+ border-top-color: var(--bo-sel-border);
2071
+ border-radius: 50%;
2072
+ animation: bo-spin 0.7s linear infinite;
2073
+ }
2074
+ @keyframes bo-spin {
2075
+ to {
2076
+ transform: rotate(360deg);
2077
+ }
2078
+ }
2079
+ @media (prefers-reduced-motion: reduce) {
2080
+ .spinner {
2081
+ animation: none;
2082
+ }
2083
+ }
1202
2084
  .spacer {
1203
2085
  position: relative;
1204
2086
  width: 100%;
@@ -1244,6 +2126,9 @@
1244
2126
  .row.clickable {
1245
2127
  cursor: pointer;
1246
2128
  }
2129
+ .row.droptarget {
2130
+ box-shadow: inset 0 2px 0 var(--bo-sel-border);
2131
+ }
1247
2132
 
1248
2133
  /* Pinned top rows: stick to the top of the viewport above the scroll. */
1249
2134
  .pinned-top {
@@ -1289,6 +2174,38 @@
1289
2174
  justify-content: flex-end;
1290
2175
  }
1291
2176
 
2177
+ /* Master-detail: leading expand-toggle column + detail panel. */
2178
+ .expandcell {
2179
+ display: flex;
2180
+ align-items: center;
2181
+ justify-content: center;
2182
+ flex: 0 0 auto;
2183
+ }
2184
+ .expand-toggle {
2185
+ width: 20px;
2186
+ height: 20px;
2187
+ padding: 0;
2188
+ font-size: 11px;
2189
+ line-height: 1;
2190
+ color: var(--bo-text-dim);
2191
+ background: transparent;
2192
+ border: 0;
2193
+ border-radius: 4px;
2194
+ cursor: pointer;
2195
+ }
2196
+ .expand-toggle:hover {
2197
+ color: var(--bo-text);
2198
+ background: var(--bo-row-hover);
2199
+ }
2200
+ .row-detail {
2201
+ position: absolute;
2202
+ left: 0;
2203
+ overflow: auto;
2204
+ background: var(--bo-row-a);
2205
+ border-bottom: 0.5px solid var(--bo-border);
2206
+ box-shadow: inset 0 1px 0 var(--bo-border);
2207
+ }
2208
+
1292
2209
  /* Leading checkbox column (row selection). */
1293
2210
  .selcell {
1294
2211
  display: flex;