bo-grid 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@
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';
@@ -22,6 +23,8 @@
22
23
  import Cell from './Cell.svelte';
23
24
  import GroupRow from './GroupRow.svelte';
24
25
  import AggregationBar from './AggregationBar.svelte';
26
+ import Pager from './Pager.svelte';
27
+ import RowMenu from './RowMenu.svelte';
25
28
 
26
29
  let {
27
30
  rows,
@@ -48,6 +51,19 @@
48
51
  onCellClick,
49
52
  pinnedRows = [],
50
53
  filterRow = false,
54
+ emptyMessage = 'No matching rows',
55
+ loading = false,
56
+ rowMenu,
57
+ detail,
58
+ detailHeight = 160,
59
+ getChildren,
60
+ onRowReorder,
61
+ pageSize = 0,
62
+ page,
63
+ onPageChange,
64
+ onColumnReorder,
65
+ onColumnResize,
66
+ ariaLabel,
51
67
  cell,
52
68
  }: {
53
69
  rows: GridRow[];
@@ -99,6 +115,40 @@
99
115
  /** Show a per-column filter input row under the header. Rows must match every
100
116
  non-empty column filter (AND). In-memory mode only. Default false. */
101
117
  filterRow?: boolean;
118
+ /** Message shown when there are no rows. Default 'No matching rows'. */
119
+ emptyMessage?: string;
120
+ /** Show a loading overlay over the grid (for consumer-driven async work in
121
+ in-memory mode; source mode shows skeleton rows automatically). */
122
+ loading?: boolean;
123
+ /** Right-click row menu. Return the items for a row; an empty array shows no
124
+ menu. Each item runs `onSelect` and closes the menu. */
125
+ rowMenu?: (row: GridRow) => Array<{ label: string; onSelect: () => void }>;
126
+ /** Master-detail: render an expandable detail panel under a row. Adds a
127
+ leading expand-toggle column. In-memory mode only (overrides rowHeight). */
128
+ detail?: Snippet<[{ row: GridRow }]>;
129
+ /** Height (px) of the expanded detail panel. Default 160. */
130
+ detailHeight?: number;
131
+ /** Tree data: return a row's children (undefined/empty = leaf). When set,
132
+ `rows` are the roots; the grid renders an indented, expandable tree.
133
+ In-memory mode; filter/sort/group/paginate are not applied to the tree. */
134
+ getChildren?: (row: GridRow) => GridRow[] | undefined;
135
+ /** Enable drag-to-reorder rows via a handle in the first column. Called with
136
+ the from/to indices (into the visible rows) on drop — reorder your own
137
+ `rows` in here. Flat, unsorted, in-memory lists only. */
138
+ onRowReorder?: (fromIndex: number, toIndex: number) => void;
139
+ /** Rows per page. When > 0 (in-memory mode), shows a pager instead of one
140
+ long scroll; rows still virtualize within a page. Default 0 (off). */
141
+ pageSize?: number;
142
+ /** Controlled current page (0-based). Omit for uncontrolled paging. */
143
+ page?: number;
144
+ /** Called with the new page index when the pager is used. */
145
+ onPageChange?: (page: number) => void;
146
+ /** Called with the new column-key order after a header drag-reorder. */
147
+ onColumnReorder?: (keys: string[]) => void;
148
+ /** Called with a column key + new width after a drag-resize. */
149
+ onColumnResize?: (key: string, width: number) => void;
150
+ /** Accessible name for the grid (`aria-label` on the `role="grid"` root). */
151
+ ariaLabel?: string;
102
152
  filter?: string;
103
153
  groupBy?: string[];
104
154
  aggregations?: AggKind[];
@@ -135,9 +185,13 @@
135
185
  const sel = new Selection();
136
186
  let dragging = $state(false);
137
187
  let editing = $state<{ r: number; c: number } | null>(null);
188
+ // When editing was opened by typing a character (type-to-edit), the editor
189
+ // seeds its input with it; null means edit the existing value (dblclick/Enter).
190
+ let editSeed = $state<string | null>(null);
138
191
 
139
- function startEdit(r: number, c: number) {
192
+ function startEdit(r: number, c: number, seed: string | null = null) {
140
193
  if (!isEditable(cols[c]) || !dataAt(r)) return;
194
+ editSeed = seed;
141
195
  editing = { r, c };
142
196
  }
143
197
  // Coerce + validate a raw string for cell (r,c) and emit onCellEdit.
@@ -161,6 +215,7 @@
161
215
 
162
216
  function commitEdit(r: number, c: number, raw: string) {
163
217
  editing = null;
218
+ editSeed = null;
164
219
  writeCell(r, c, raw);
165
220
  }
166
221
 
@@ -181,9 +236,30 @@
181
236
  // Whole-row selection (opt-in), keyed by row id so it survives sort/filter.
182
237
  // Plain Set + a version counter for reactivity (same pattern as `collapsed`).
183
238
  const SEL_W = 40; // checkbox column width (px)
239
+ const EXP_W = 30; // master-detail expand column width (px)
184
240
  const selectedRows = new Set<string | number>();
185
241
  let selRowsVersion = $state(0);
242
+
243
+ // Master-detail expansion (opt-in via `detail`), keyed by row id.
244
+ const expandedRows = new Set<string | number>();
245
+ let expVersion = $state(0);
246
+ const expandable = $derived(!!detail && !source);
247
+
248
+ // Leading fixed columns: expand toggle (if any) then checkbox (if any).
186
249
  const selOffset = $derived(rowSelection ? 1 : 0);
250
+ const expOffset = $derived(expandable ? 1 : 0);
251
+ const leadCols = $derived(selOffset + expOffset); // count, for aria indices
252
+ const leadPx = $derived(selOffset * SEL_W + expOffset * EXP_W); // px, for sticky offsets
253
+
254
+ function isExpanded(id: string | number): boolean {
255
+ expVersion; // track
256
+ return expandedRows.has(id);
257
+ }
258
+ function toggleExpand(id: string | number): void {
259
+ if (expandedRows.has(id)) expandedRows.delete(id);
260
+ else expandedRows.add(id);
261
+ expVersion++;
262
+ }
187
263
 
188
264
  function isRowSelected(id: string | number): boolean {
189
265
  selRowsVersion; // track
@@ -237,24 +313,38 @@
237
313
 
238
314
  // Pinned columns sit to the right of the (also-sticky) checkbox column, so
239
315
  // their sticky-left offsets shift by SEL_W when row selection is on.
316
+ // Sticky position for a pinned column: left columns offset past the (also
317
+ // sticky) checkbox column; right columns offset from the right edge.
318
+ function pinStick(ci: number): string {
319
+ const inf = layout.info[ci];
320
+ if (inf.side === 'right') return `position:sticky;right:${inf.right}px;`;
321
+ return `position:sticky;left:${inf.left + leadPx}px;`;
322
+ }
240
323
  function headStyle(ci: number): string {
241
324
  if (!pinned) return colStyle(cols[ci]);
242
325
  const inf = layout.info[ci];
243
326
  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);`;
327
+ if (inf.pinned) s += `${pinStick(ci)}z-index:5;background:var(--bo-header-bg);`;
245
328
  return s;
246
329
  }
247
330
  function cellWidthStyle(ci: number): string {
248
331
  if (!pinned) return colStyle(cols[ci]);
249
332
  const inf = layout.info[ci];
250
333
  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);`;
334
+ if (inf.pinned) s += `${pinStick(ci)}z-index:1;background:var(--bo-bg);`;
252
335
  return s;
253
336
  }
254
- // The leading checkbox column: a fixed-width flex item, sticky-left when the
255
- // grid scrolls horizontally (pinned mode).
337
+ // The leading checkbox column: a fixed-width flex item, sticky-left (past the
338
+ // expand column, if any) when the grid scrolls horizontally (pinned mode).
256
339
  function selCellStyle(header: boolean): string {
257
340
  let s = `flex:0 0 ${SEL_W}px;width:${SEL_W}px;`;
341
+ if (pinned)
342
+ s += `position:sticky;left:${expOffset * EXP_W}px;z-index:${header ? 6 : 2};background:var(--bo-${header ? 'header-bg' : 'bg'});`;
343
+ return s;
344
+ }
345
+ // The leading expand-toggle column (master-detail), sticky at the far left.
346
+ function expandCellStyle(header: boolean): string {
347
+ let s = `flex:0 0 ${EXP_W}px;width:${EXP_W}px;`;
258
348
  if (pinned)
259
349
  s += `position:sticky;left:0;z-index:${header ? 6 : 2};background:var(--bo-${header ? 'header-bg' : 'bg'});`;
260
350
  return s;
@@ -301,6 +391,7 @@
301
391
  /* storage unavailable — order still applies this session */
302
392
  }
303
393
  }
394
+ onColumnReorder?.(next.map((i) => columns[i].key));
304
395
  }
305
396
 
306
397
  // ---- Column resizing -------------------------------------------------------
@@ -331,7 +422,7 @@
331
422
  });
332
423
  });
333
424
 
334
- let resize: { key: string; startX: number; startW: number } | null = null;
425
+ let resize: { key: string; startX: number; startW: number; min?: number; max?: number } | null = null;
335
426
  let justResized = false;
336
427
 
337
428
  function startResize(ci: number, e: PointerEvent) {
@@ -340,22 +431,24 @@
340
431
  e.stopPropagation();
341
432
  const headCell = (e.currentTarget as HTMLElement).closest('.h') as HTMLElement | null;
342
433
  const startW = headCell ? headCell.getBoundingClientRect().width : layout.info[ci].width;
343
- resize = { key: cols[ci].key, startX: e.clientX, startW };
434
+ resize = { key: cols[ci].key, startX: e.clientX, startW, min: cols[ci].minWidth, max: cols[ci].maxWidth };
344
435
  window.addEventListener('pointermove', onResizeMove);
345
436
  window.addEventListener('pointerup', onResizeUp);
346
437
  }
347
438
  function onResizeMove(e: PointerEvent) {
348
439
  if (!resize) return;
349
- const w = clampWidth(resize.startW + (e.clientX - resize.startX));
440
+ const w = clampWidth(resize.startW + (e.clientX - resize.startX), resize.min, resize.max);
350
441
  widths = { ...widths, [resize.key]: w };
351
442
  }
352
443
  function onResizeUp() {
353
444
  if (!resize) return;
445
+ const { key } = resize;
354
446
  resize = null;
355
447
  justResized = true; // swallow the click that ends this drag (no sort toggle)
356
448
  window.removeEventListener('pointermove', onResizeMove);
357
449
  window.removeEventListener('pointerup', onResizeUp);
358
450
  persistWidths();
451
+ onColumnResize?.(key, widths[key]);
359
452
  }
360
453
  /** Double-click a resize grip to clear the override and restore the default. */
361
454
  function resetWidth(ci: number, e: MouseEvent) {
@@ -421,18 +514,75 @@
421
514
  if (colF.length > 0) {
422
515
  r = r.filter((row) => colF.every(([k, v]) => String(row[k] ?? '').toLowerCase().includes(v)));
423
516
  }
424
- if (s.length > 0) r = [...r].sort((a, b) => compareBySorts(a, b, s));
517
+ if (s.length > 0) {
518
+ const colOf = (k: string) => allCols.find((c) => c.key === k);
519
+ r = [...r].sort((a, b) => compareBySorts(a, b, s, colOf));
520
+ }
425
521
  return r;
426
522
  });
427
523
  });
428
524
 
525
+ // Pagination (in-memory only): slice the view into pages; rows still
526
+ // virtualize within a page. Off when pageSize <= 0.
527
+ const paged = $derived(pageSize > 0 && !source);
528
+ let internalPage = $state(0);
529
+ const currentPage = $derived(page ?? internalPage); // controlled by `page` prop, else internal
530
+ function setPage(p: number): void {
531
+ if (page === undefined) internalPage = p; // uncontrolled: own the state
532
+ onPageChange?.(p); // always notify
533
+ }
534
+ const pageCount = $derived(paged ? Math.max(1, Math.ceil(view.length / pageSize)) : 1);
535
+ // Keep the page in range when the view shrinks (filter/sort changes).
536
+ $effect(() => {
537
+ const max = pageCount - 1;
538
+ untrack(() => {
539
+ if (currentPage > max) setPage(max);
540
+ });
541
+ });
542
+ const pageRows = $derived(
543
+ paged ? view.slice(currentPage * pageSize, currentPage * pageSize + pageSize) : view,
544
+ );
545
+
546
+ const treeData = $derived(!!getChildren && !source);
547
+
548
+ // Drag-to-reorder rows (flat, unsorted, in-memory only). The handle lives in
549
+ // the first cell; the dragged/drop indices are tracked in component state.
550
+ const reorderable = $derived(
551
+ !!onRowReorder && !source && !treeData && groupBy.length === 0 && pageSize <= 0,
552
+ );
553
+ let dragRowVr = $state(-1);
554
+ let dropRowVr = $state(-1);
555
+ function onRowDrop() {
556
+ if (dragRowVr >= 0 && dropRowVr >= 0 && dragRowVr !== dropRowVr) {
557
+ onRowReorder?.(dragRowVr, dropRowVr);
558
+ }
559
+ dragRowVr = -1;
560
+ dropRowVr = -1;
561
+ }
562
+
429
563
  const flat = $derived.by<VisualRow[]>(() => {
430
- const v = view;
564
+ if (treeData && getChildren) {
565
+ const roots = rows;
566
+ expVersion; // track expand/collapse
567
+ return untrack(() =>
568
+ buildTreeRows(roots, getChildren, (r) => expandedRows.has(getRowId(r))),
569
+ );
570
+ }
571
+ const v = pageRows;
431
572
  const gb = groupBy;
432
573
  collapsedVersion;
433
574
  return untrack(() => buildFlatRows(v, gb, collapsed));
434
575
  });
435
576
 
577
+ function goToPage(p: number): void {
578
+ const next = Math.max(0, Math.min(p, pageCount - 1));
579
+ if (next === currentPage) return;
580
+ setPage(next);
581
+ sel.clear();
582
+ editing = null;
583
+ if (viewportEl) viewportEl.scrollTop = 0;
584
+ }
585
+
436
586
  // Header select-all state over the in-memory rows (source mode can't enumerate
437
587
  // unloaded ids, so the header checkbox is disabled there).
438
588
  const selectAll = $derived.by(() => {
@@ -483,22 +633,32 @@
483
633
  // a prefix-sum model (in-memory only — source mode can't know unloaded heights).
484
634
  const baseH = $derived(typeof rowHeight === 'number' && rowHeight > 0 ? rowHeight : ROW_H);
485
635
  const variable = $derived(typeof rowHeight === 'function' && !source);
636
+ // Use a prefix-sum height model when row heights vary: a `rowHeight` function,
637
+ // or master-detail expansion (expanded rows are taller by `detailHeight`).
638
+ const useHeights = $derived(variable || expandable);
486
639
  const heights = $derived.by<number[] | null>(() => {
487
- if (!variable) return null;
488
- const fn = rowHeight as (row: GridRow, index: number) => number;
640
+ if (!useHeights) return null;
641
+ const fn = variable ? (rowHeight as (row: GridRow, index: number) => number) : null;
642
+ expVersion; // track expansion changes
489
643
  const arr = new Array<number>(flat.length);
490
644
  let di = 0;
491
645
  for (let i = 0; i < flat.length; i++) {
492
646
  const it = flat[i];
493
- arr[i] = it.kind === 'data' ? Math.max(1, fn(it.row, di++)) : baseH;
647
+ if (it.kind !== 'data') {
648
+ arr[i] = baseH;
649
+ continue;
650
+ }
651
+ let h = fn ? Math.max(1, fn(it.row, di++)) : baseH;
652
+ if (expandable && expandedRows.has(getRowId(it.row))) h += detailHeight;
653
+ arr[i] = h;
494
654
  }
495
655
  return arr;
496
656
  });
497
- const hm = $derived(variable && heights ? variableHeights(heights) : uniformHeights(rowCount, baseH));
657
+ const hm = $derived(useHeights && heights ? variableHeights(heights) : uniformHeights(rowCount, baseH));
498
658
 
499
659
  const total = $derived(hm.total);
500
660
  const rowWidthStyle = $derived(
501
- pinned ? `width:${layout.totalWidth + selOffset * SEL_W}px;right:auto;` : '',
661
+ pinned ? `width:${layout.totalWidth + leadPx}px;right:auto;` : '',
502
662
  );
503
663
  const visibleCount = $derived(Math.ceil(height / baseH) + OVERSCAN * 2);
504
664
  const start = $derived(Math.max(0, hm.indexAt(scrollTop) - OVERSCAN));
@@ -510,7 +670,7 @@
510
670
 
511
671
  type RenderItem =
512
672
  | { vr: number; kind: 'group'; group: GroupNode }
513
- | { vr: number; kind: 'data'; row: GridRow }
673
+ | { vr: number; kind: 'data'; row: GridRow; depth?: number; hasChildren?: boolean }
514
674
  | { vr: number; kind: 'skeleton' };
515
675
 
516
676
  const renderItems = $derived.by<RenderItem[]>(() => {
@@ -526,7 +686,7 @@
526
686
  const item = flat[vr];
527
687
  if (!item) continue;
528
688
  if (item.kind === 'group') out.push({ vr, kind: 'group', group: item.group });
529
- else out.push({ vr, kind: 'data', row: item.row });
689
+ else out.push({ vr, kind: 'data', row: item.row, depth: item.depth, hasChildren: item.hasChildren });
530
690
  }
531
691
  }
532
692
  return out;
@@ -598,6 +758,29 @@
598
758
  if (dragging) sel.extendTo(r, c);
599
759
  }
600
760
 
761
+ // Right-click row menu (floating).
762
+ let menu = $state<{ x: number; y: number; items: Array<{ label: string; onSelect: () => void }> } | null>(null);
763
+ function openRowMenu(row: GridRow, e: MouseEvent) {
764
+ if (!rowMenu) return;
765
+ const items = rowMenu(row);
766
+ if (items.length === 0) return;
767
+ e.preventDefault();
768
+ menu = { x: e.clientX, y: e.clientY, items };
769
+ }
770
+ $effect(() => {
771
+ if (!menu) return;
772
+ const close = () => (menu = null);
773
+ const onKey = (e: KeyboardEvent) => e.key === 'Escape' && (menu = null);
774
+ window.addEventListener('pointerdown', close);
775
+ window.addEventListener('keydown', onKey);
776
+ window.addEventListener('blur', close);
777
+ return () => {
778
+ window.removeEventListener('pointerdown', close);
779
+ window.removeEventListener('keydown', onKey);
780
+ window.removeEventListener('blur', close);
781
+ };
782
+ });
783
+
601
784
  function onCellClicked(r: number, c: number, e: MouseEvent) {
602
785
  if (!onCellClick) return;
603
786
  const row = dataAt(r);
@@ -636,7 +819,7 @@
636
819
  const cells: string[] = [];
637
820
  for (let c = b.c0; c <= cEnd; c++) {
638
821
  const col = cols[c];
639
- cells.push(col.type === 'sparkline' || col.type === 'custom' ? '' : formatCell(col, row[col.key]));
822
+ cells.push(col.type === 'sparkline' || col.type === 'custom' ? '' : formatCell(col, row[col.key], row));
640
823
  }
641
824
  lines.push(cells.join('\t'));
642
825
  }
@@ -708,6 +891,28 @@
708
891
  }
709
892
  }
710
893
  }
894
+ // Space toggles selection of the focused row (when the checkbox column is on),
895
+ // so keyboard users can tick rows without reaching for the mouse.
896
+ if (e.key === ' ' && rowSelection && sel.focus && !editing) {
897
+ const row = dataAt(sel.focus.r);
898
+ if (row) {
899
+ e.preventDefault();
900
+ toggleRow(getRowId(row));
901
+ return;
902
+ }
903
+ }
904
+ // Type-to-edit (Excel-style): a printable key on a focused editable text/number
905
+ // cell opens the editor seeded with that character. Select-column editors keep
906
+ // their Enter-to-open behavior.
907
+ if (!mod && !e.altKey && e.key.length === 1 && e.key !== ' ' && sel.focus && !editing) {
908
+ const f = sel.focus;
909
+ const col = cols[f.c];
910
+ if (isEditable(col) && !(col.options && col.options.length) && dataAt(f.r)) {
911
+ e.preventDefault();
912
+ startEdit(f.r, f.c, e.key);
913
+ return;
914
+ }
915
+ }
711
916
  if (mod && e.key.toLowerCase() === 'a') {
712
917
  e.preventDefault();
713
918
  sel.selectAll(rowCount, cols.length);
@@ -727,6 +932,36 @@
727
932
  sel.clear();
728
933
  return;
729
934
  }
935
+ // Open the row menu from the keyboard (ContextMenu key / Shift+F10).
936
+ if (rowMenu && sel.focus && (e.key === 'ContextMenu' || (e.shiftKey && e.key === 'F10'))) {
937
+ const row = dataAt(sel.focus.r);
938
+ const items = row ? rowMenu(row) : [];
939
+ if (items.length > 0) {
940
+ e.preventDefault();
941
+ const rect = document.getElementById(activeId ?? '')?.getBoundingClientRect();
942
+ menu = { x: rect?.left ?? 0, y: rect?.bottom ?? 0, items };
943
+ return;
944
+ }
945
+ }
946
+ // Tree nav: ArrowRight expands a collapsed node, ArrowLeft collapses an
947
+ // expanded one (treegrid pattern); otherwise arrows move normally.
948
+ if (treeData && sel.focus && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) {
949
+ const item = flat[sel.focus.r];
950
+ if (item?.kind === 'data' && item.hasChildren) {
951
+ const id = getRowId(item.row);
952
+ const open = expandedRows.has(id);
953
+ if (e.key === 'ArrowRight' && !open) {
954
+ e.preventDefault();
955
+ toggleExpand(id);
956
+ return;
957
+ }
958
+ if (e.key === 'ArrowLeft' && open) {
959
+ e.preventDefault();
960
+ toggleExpand(id);
961
+ return;
962
+ }
963
+ }
964
+ }
730
965
  // Home/End (row, or whole grid with Ctrl/⌘); PageUp/PageDown by a viewport page.
731
966
  const f = sel.focus;
732
967
  if (f && (e.key === 'Home' || e.key === 'End' || e.key === 'PageUp' || e.key === 'PageDown')) {
@@ -777,8 +1012,9 @@
777
1012
  role="grid"
778
1013
  tabindex="0"
779
1014
  id={gid}
1015
+ aria-label={ariaLabel}
780
1016
  aria-rowcount={rowCount + 1}
781
- aria-colcount={cols.length + selOffset}
1017
+ aria-colcount={cols.length + leadCols}
782
1018
  aria-multiselectable="true"
783
1019
  aria-activedescendant={activeId}
784
1020
  style={themeStyle}
@@ -787,6 +1023,7 @@
787
1023
  >
788
1024
  {#if headerGroups}
789
1025
  <div class="head-groups" aria-hidden="true" bind:this={groupHeadEl} style={pinned ? 'overflow:hidden;' : ''}>
1026
+ {#if expandable}<span class="expandcell" style={expandCellStyle(true)}></span>{/if}
790
1027
  {#if rowSelection}<span class="selcell" style={selCellStyle(true)}></span>{/if}
791
1028
  {#each headerGroups as g, gi (gi)}
792
1029
  <span class="hg" class:empty={!g.label} style="flex:0 0 {g.width}px;width:{g.width}px;">{g.label}</span>
@@ -794,8 +1031,11 @@
794
1031
  </div>
795
1032
  {/if}
796
1033
  <div class="head" role="row" aria-rowindex={1} bind:this={headEl} style={pinned ? 'overflow:hidden;' : ''}>
1034
+ {#if expandable}
1035
+ <span class="expandcell selhead" role="columnheader" aria-colindex={1} style={expandCellStyle(true)}></span>
1036
+ {/if}
797
1037
  {#if rowSelection}
798
- <span class="selcell selhead" role="columnheader" aria-colindex={1} style={selCellStyle(true)}>
1038
+ <span class="selcell selhead" role="columnheader" aria-colindex={1 + expOffset} style={selCellStyle(true)}>
799
1039
  <input
800
1040
  type="checkbox"
801
1041
  class="rowcheck"
@@ -810,7 +1050,7 @@
810
1050
  {/if}
811
1051
  {#each cols as col, ci (ci)}
812
1052
  <button
813
- class="h"
1053
+ class="h {col.headerClass ?? ''}"
814
1054
  class:right={isNumeric(col) || col.align === 'right'}
815
1055
  class:sortable={isSortable(col)}
816
1056
  class:dragging={ci === dragSrc}
@@ -818,7 +1058,7 @@
818
1058
  style={headStyle(ci)}
819
1059
  type="button"
820
1060
  role="columnheader"
821
- aria-colindex={ci + 1 + selOffset}
1061
+ aria-colindex={ci + 1 + leadCols}
822
1062
  draggable="true"
823
1063
  aria-sort={isSortable(col) && sortInfo(col.key)
824
1064
  ? sortInfo(col.key)?.dir === 'asc'
@@ -873,6 +1113,7 @@
873
1113
 
874
1114
  {#if filterRow && !source}
875
1115
  <div class="filter-row" role="row" bind:this={filterRowEl} style={pinned ? 'overflow:hidden;' : ''}>
1116
+ {#if expandable}<span class="expandcell" style={expandCellStyle(false)}></span>{/if}
876
1117
  {#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
877
1118
  {#each cols as col, ci (ci)}
878
1119
  <span class="fr-cell" style={cellWidthStyle(ci)}>
@@ -901,6 +1142,7 @@
901
1142
  <div class="pinned-top">
902
1143
  {#each pinnedRows as prow, pi (getRowId(prow))}
903
1144
  <div class="row pinrow {rowClass?.(prow) ?? ''}" role="row" aria-hidden="true" style="height:{baseH}px;{rowWidthStyle}">
1145
+ {#if expandable}<span class="expandcell" style={expandCellStyle(false)}></span>{/if}
904
1146
  {#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
905
1147
  {#each cols as col, ci (ci)}
906
1148
  <Cell
@@ -908,11 +1150,12 @@
908
1150
  row={prow}
909
1151
  r={-1 - pi}
910
1152
  c={ci}
911
- colIndex={ci + 1 + selOffset}
1153
+ colIndex={ci + 1 + leadCols}
912
1154
  cellId={`${gid}-pin${pi}-c${ci}`}
913
1155
  cellSnippet={cell}
914
1156
  pinned={pinned && layout.info[ci].pinned}
915
- pinLeft={layout.info[ci].left + selOffset * SEL_W}
1157
+ pinSide={layout.info[ci].side ?? 'left'}
1158
+ pinOffset={layout.info[ci].side === 'right' ? layout.info[ci].right : layout.info[ci].left + leadPx}
916
1159
  width={pinned ? layout.info[ci].width : undefined}
917
1160
  />
918
1161
  {/each}
@@ -921,7 +1164,13 @@
921
1164
  </div>
922
1165
  {/if}
923
1166
  {#if rowCount === 0 && !controller?.loading}
924
- <div class="empty">No matching rows</div>
1167
+ <div class="empty">{emptyMessage}</div>
1168
+ {/if}
1169
+ {#if loading}
1170
+ <div class="loading-overlay" aria-busy="true" aria-live="polite">
1171
+ <span class="spinner" aria-hidden="true"></span>
1172
+ <span class="loading-label">Loading…</span>
1173
+ </div>
925
1174
  {/if}
926
1175
  {#if stickyGroups.length > 0}
927
1176
  <div class="sticky">
@@ -932,15 +1181,17 @@
932
1181
  {/each}
933
1182
  </div>
934
1183
  {/if}
935
- <div class="spacer" style="height:{total}px;{pinned ? `width:${layout.totalWidth + selOffset * SEL_W}px;` : ''}">
1184
+ <div class="spacer" style="height:{total}px;{pinned ? `width:${layout.totalWidth + leadPx}px;` : ''}">
936
1185
  {#each renderItems as item (item.vr)}
937
1186
  {#if item.kind === 'group'}
938
1187
  <div class="grouprow" style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}">
1188
+ {#if expandable}<span class="expandcell" aria-hidden="true" style={expandCellStyle(false)}></span>{/if}
939
1189
  {#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
940
1190
  <GroupRow group={item.group} columns={cols} onToggle={toggleGroup} rowIndex={item.vr + 2} />
941
1191
  </div>
942
1192
  {:else if item.kind === 'skeleton'}
943
1193
  <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}">
1194
+ {#if expandable}<span class="expandcell" style={expandCellStyle(false)}></span>{/if}
944
1195
  {#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
945
1196
  {#each cols as col, ci (ci)}
946
1197
  <span class="c" style={cellWidthStyle(ci)}><span class="skelbar"></span></span>
@@ -949,7 +1200,24 @@
949
1200
  {:else}
950
1201
  <!-- Row activation is keyboard-accessible at the grid level: Enter on the focused cell fires onRowClick (focus is via aria-activedescendant). -->
951
1202
  <!-- 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)}>
1203
+ <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}>
1204
+ {#if expandable}
1205
+ <span class="expandcell" style={expandCellStyle(false)}>
1206
+ <button
1207
+ class="expand-toggle"
1208
+ type="button"
1209
+ aria-expanded={isExpanded(getRowId(item.row))}
1210
+ aria-label="Toggle detail"
1211
+ onpointerdown={(e) => e.stopPropagation()}
1212
+ onclick={(e) => {
1213
+ e.stopPropagation();
1214
+ toggleExpand(getRowId(item.row));
1215
+ }}
1216
+ >
1217
+ {isExpanded(getRowId(item.row)) ? '▾' : '▸'}
1218
+ </button>
1219
+ </span>
1220
+ {/if}
953
1221
  {#if rowSelection}
954
1222
  <span class="selcell" style={selCellStyle(false)}>
955
1223
  <input
@@ -969,30 +1237,52 @@
969
1237
  row={item.row}
970
1238
  r={item.vr}
971
1239
  c={ci}
972
- colIndex={ci + 1 + selOffset}
1240
+ colIndex={ci + 1 + leadCols}
973
1241
  cellId={`${gid}-r${item.vr}-c${ci}`}
974
1242
  cellSnippet={cell}
975
1243
  selected={sel.contains(item.vr, ci)}
976
1244
  focused={sel.isFocus(item.vr, ci)}
977
1245
  pinned={pinned && layout.info[ci].pinned}
978
- pinLeft={layout.info[ci].left + selOffset * SEL_W}
1246
+ pinSide={layout.info[ci].side ?? 'left'}
1247
+ pinOffset={layout.info[ci].side === 'right' ? layout.info[ci].right : layout.info[ci].left + leadPx}
979
1248
  width={pinned ? layout.info[ci].width : undefined}
980
1249
  alt={item.vr % 2 === 1}
981
1250
  editing={editing?.r === item.vr && editing?.c === ci}
1251
+ seed={editing?.r === item.vr && editing?.c === ci ? editSeed : null}
1252
+ tree={treeData && ci === 0
1253
+ ? {
1254
+ depth: item.depth ?? 0,
1255
+ hasChildren: item.hasChildren ?? false,
1256
+ expanded: isExpanded(getRowId(item.row)),
1257
+ onToggle: () => toggleExpand(getRowId(item.row)),
1258
+ }
1259
+ : undefined}
1260
+ dragHandle={reorderable && ci === 0
1261
+ ? { onStart: () => (dragRowVr = item.vr), onEnd: () => (dragRowVr = -1) }
1262
+ : undefined}
982
1263
  {onCellDown}
983
1264
  {onCellEnter}
984
1265
  onCellClick={onCellClick ? onCellClicked : undefined}
985
1266
  onCellDblClick={startEdit}
986
1267
  onEditCommit={(raw) => commitEdit(item.vr, ci, raw)}
987
- onEditCancel={() => (editing = null)}
1268
+ onEditCancel={() => {
1269
+ editing = null;
1270
+ editSeed = null;
1271
+ }}
988
1272
  />
989
1273
  {/each}
990
1274
  </div>
1275
+ {#if expandable && detail && isExpanded(getRowId(item.row))}
1276
+ <div class="row-detail" style="top:{hm.offsetOf(item.vr) + baseH}px;height:{detailHeight}px;{rowWidthStyle}">
1277
+ {@render detail({ row: item.row })}
1278
+ </div>
1279
+ {/if}
991
1280
  {/if}
992
1281
  {/each}
993
1282
  </div>
994
1283
  {#if footerCells}
995
- <div class="footer" role="row" style={pinned ? `width:${layout.totalWidth + selOffset * SEL_W}px;` : ''}>
1284
+ <div class="footer" role="row" style={pinned ? `width:${layout.totalWidth + leadPx}px;` : ''}>
1285
+ {#if expandable}<span class="expandcell" aria-hidden="true" style={expandCellStyle(false)}></span>{/if}
996
1286
  {#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
997
1287
  {#each cols as col, ci (ci)}
998
1288
  <span class="fcell" class:right={isNumeric(col)} style={cellWidthStyle(ci)}>
@@ -1004,6 +1294,14 @@
1004
1294
  </div>
1005
1295
 
1006
1296
  <AggregationBar result={agg} kinds={aggregations} />
1297
+
1298
+ {#if paged}
1299
+ <Pager page={currentPage} {pageCount} total={view.length} onGoto={goToPage} />
1300
+ {/if}
1301
+
1302
+ {#if menu}
1303
+ <RowMenu x={menu.x} y={menu.y} items={menu.items} onClose={() => (menu = null)} />
1304
+ {/if}
1007
1305
  </div>
1008
1306
 
1009
1307
  <style>
@@ -1199,6 +1497,40 @@
1199
1497
  color: var(--bo-text-dim);
1200
1498
  font-size: 13px;
1201
1499
  }
1500
+ .loading-overlay {
1501
+ position: sticky;
1502
+ top: 0;
1503
+ left: 0;
1504
+ z-index: 7;
1505
+ display: flex;
1506
+ align-items: center;
1507
+ justify-content: center;
1508
+ gap: 10px;
1509
+ height: 100%;
1510
+ margin-bottom: -100%; /* overlay without consuming scroll height */
1511
+ color: var(--bo-text-dim);
1512
+ font-size: 13px;
1513
+ background: color-mix(in srgb, var(--bo-bg) 70%, transparent);
1514
+ backdrop-filter: blur(1px);
1515
+ }
1516
+ .spinner {
1517
+ width: 18px;
1518
+ height: 18px;
1519
+ border: 2px solid var(--bo-border);
1520
+ border-top-color: var(--bo-sel-border);
1521
+ border-radius: 50%;
1522
+ animation: bo-spin 0.7s linear infinite;
1523
+ }
1524
+ @keyframes bo-spin {
1525
+ to {
1526
+ transform: rotate(360deg);
1527
+ }
1528
+ }
1529
+ @media (prefers-reduced-motion: reduce) {
1530
+ .spinner {
1531
+ animation: none;
1532
+ }
1533
+ }
1202
1534
  .spacer {
1203
1535
  position: relative;
1204
1536
  width: 100%;
@@ -1244,6 +1576,9 @@
1244
1576
  .row.clickable {
1245
1577
  cursor: pointer;
1246
1578
  }
1579
+ .row.droptarget {
1580
+ box-shadow: inset 0 2px 0 var(--bo-sel-border);
1581
+ }
1247
1582
 
1248
1583
  /* Pinned top rows: stick to the top of the viewport above the scroll. */
1249
1584
  .pinned-top {
@@ -1289,6 +1624,38 @@
1289
1624
  justify-content: flex-end;
1290
1625
  }
1291
1626
 
1627
+ /* Master-detail: leading expand-toggle column + detail panel. */
1628
+ .expandcell {
1629
+ display: flex;
1630
+ align-items: center;
1631
+ justify-content: center;
1632
+ flex: 0 0 auto;
1633
+ }
1634
+ .expand-toggle {
1635
+ width: 20px;
1636
+ height: 20px;
1637
+ padding: 0;
1638
+ font-size: 11px;
1639
+ line-height: 1;
1640
+ color: var(--bo-text-dim);
1641
+ background: transparent;
1642
+ border: 0;
1643
+ border-radius: 4px;
1644
+ cursor: pointer;
1645
+ }
1646
+ .expand-toggle:hover {
1647
+ color: var(--bo-text);
1648
+ background: var(--bo-row-hover);
1649
+ }
1650
+ .row-detail {
1651
+ position: absolute;
1652
+ left: 0;
1653
+ overflow: auto;
1654
+ background: var(--bo-row-a);
1655
+ border-bottom: 0.5px solid var(--bo-border);
1656
+ box-shadow: inset 0 1px 0 var(--bo-border);
1657
+ }
1658
+
1292
1659
  /* Leading checkbox column (row selection). */
1293
1660
  .selcell {
1294
1661
  display: flex;