bo-grid 0.7.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +236 -17
  2. package/dist/bo-grid.FilterMenu-BHI6rILc.js +154 -0
  3. package/dist/bo-grid.ToolPanel-C3u-4YKc.js +34 -0
  4. package/dist/bo-grid.element-DPnHUXMa.js +6623 -0
  5. package/dist/bo-grid.element.js +4 -0
  6. package/dist/charts/BarChart.svelte +50 -0
  7. package/dist/charts/BarChart.svelte.d.ts +16 -0
  8. package/dist/charts/DonutChart.svelte +54 -0
  9. package/dist/charts/DonutChart.svelte.d.ts +18 -0
  10. package/dist/charts/Legend.svelte +47 -0
  11. package/dist/charts/Legend.svelte.d.ts +12 -0
  12. package/dist/charts/LineChart.svelte +59 -0
  13. package/dist/charts/LineChart.svelte.d.ts +14 -0
  14. package/dist/charts/StackedBarChart.svelte +56 -0
  15. package/dist/charts/StackedBarChart.svelte.d.ts +18 -0
  16. package/dist/charts/chart-math.d.ts +57 -0
  17. package/dist/charts/chart-math.js +174 -0
  18. package/dist/charts/index.d.ts +8 -0
  19. package/dist/charts/index.js +11 -0
  20. package/dist/charts/palette.d.ts +4 -0
  21. package/dist/charts/palette.js +14 -0
  22. package/dist/format/format.d.ts +6 -0
  23. package/dist/format/format.js +41 -0
  24. package/dist/grid/Cell.svelte +249 -10
  25. package/dist/grid/Cell.svelte.d.ts +6 -0
  26. package/dist/grid/FilterMenu.svelte +7 -0
  27. package/dist/grid/Grid.svelte +338 -87
  28. package/dist/grid/Grid.svelte.d.ts +19 -0
  29. package/dist/grid/GroupRow.svelte +5 -2
  30. package/dist/grid/Pager.svelte +4 -0
  31. package/dist/grid/RowMenu.svelte +65 -2
  32. package/dist/grid/ToolPanel.svelte +5 -0
  33. package/dist/grid/column.d.ts +133 -0
  34. package/dist/grid/column.js +133 -4
  35. package/dist/grid/colvirt.d.ts +15 -0
  36. package/dist/grid/colvirt.js +43 -0
  37. package/dist/grid/export.js +5 -2
  38. package/dist/grid/filtering.d.ts +5 -2
  39. package/dist/grid/filtering.js +5 -4
  40. package/dist/grid/grouping.d.ts +30 -0
  41. package/dist/grid/grouping.js +33 -0
  42. package/dist/grid/theme.d.ts +25 -0
  43. package/dist/grid/theme.js +84 -0
  44. package/dist/grid/tree.d.ts +19 -7
  45. package/dist/grid/tree.js +16 -11
  46. package/dist/index.d.ts +5 -4
  47. package/dist/index.js +2 -2
  48. package/dist/sparkline/Sparkline.svelte +8 -2
  49. package/dist/sparkline/sparkline-render.d.ts +4 -1
  50. package/dist/sparkline/sparkline-render.js +5 -3
  51. package/package.json +12 -2
@@ -7,13 +7,14 @@
7
7
  import { untrack } from 'svelte';
8
8
  import type { Snippet } from 'svelte';
9
9
  import type { ColumnDef, GridRow, SortState, SortDir, CellEditEvent } from './column';
10
- import { colStyle, isNumeric, isSortable, isEditable, compareBySorts, formatCell } from './column';
10
+ import { colStyle, isNumeric, isSortable, isEditable, compareBySorts, formatCell, cellValue } from './column';
11
11
  import { arrangePinned } from './pin';
12
+ import { columnWindow, columnOffsets } from './colvirt';
12
13
  import { uniformHeights, variableHeights } from './rowheight';
13
14
  import { themeVars, lightTheme, type GridTheme } from './theme';
14
15
  import { Selection } from './selection.svelte';
15
16
  import { aggregate, type AggKind, type AggResult } from './aggregate';
16
- import { buildFlatRows, activeGroupsAt, type VisualRow, type GroupNode } from './grouping';
17
+ import { buildFlatRows, buildLazyGroupRows, activeGroupsAt, type VisualRow, type GroupNode, type LazyGroup } from './grouping';
17
18
  import { buildTreeRows } from './tree';
18
19
  import { moveIndex } from './reorder';
19
20
  import { parseClipboard, isSingleCell } from './clipboard';
@@ -53,6 +54,7 @@
53
54
  onColumnVisibilityChange,
54
55
  columnMenu = false,
55
56
  columnsPanel = false,
57
+ virtualizeColumns = false,
56
58
  rowClass,
57
59
  getRowId = (r: GridRow) => r.id,
58
60
  onRowClick,
@@ -73,6 +75,10 @@
73
75
  detail,
74
76
  detailHeight = 160,
75
77
  getChildren,
78
+ loadChildren,
79
+ hasChildren,
80
+ lazyGroups,
81
+ loadGroup,
76
82
  onRowReorder,
77
83
  pageSize = 0,
78
84
  page,
@@ -112,6 +118,10 @@
112
118
  (the place to restore columns hidden via the menu). Lazy-loaded. Default
113
119
  false. */
114
120
  columnsPanel?: boolean;
121
+ /** Render only the columns within the horizontal scroll window (+ overscan)
122
+ for very wide grids (100+ columns). Forces fixed-width horizontal scroll;
123
+ pinned columns always render. Default false (fit-to-width). */
124
+ virtualizeColumns?: boolean;
115
125
  /** Return extra CSS class(es) for a data row (e.g. to colour by value).
116
126
  Style them via `:global(.your-class)` since rows live inside the grid. */
117
127
  rowClass?: (row: GridRow) => string | undefined;
@@ -179,6 +189,20 @@
179
189
  `rows` are the roots; the grid renders an indented, expandable tree.
180
190
  In-memory mode; filter/sort/group/paginate are not applied to the tree. */
181
191
  getChildren?: (row: GridRow) => GridRow[] | undefined;
192
+ /** Async tree data: load a row's children on expand (server-backed trees).
193
+ Returns a promise; the grid shows a loading row, then caches the result.
194
+ Pair with `hasChildren` (a cheap predicate) so chevrons show without
195
+ loading. Use instead of `getChildren`. In-memory roots. */
196
+ loadChildren?: (row: GridRow) => Promise<GridRow[]>;
197
+ /** Cheap predicate for whether a row has children (drives the expand chevron
198
+ without loading). Required with `loadChildren`; optional with `getChildren`. */
199
+ hasChildren?: (row: GridRow) => boolean;
200
+ /** Server-side grouping: top-level group summaries (header data, no leaf rows).
201
+ Each group's rows load on expand via `loadGroup`. In-memory mode. */
202
+ lazyGroups?: LazyGroup[];
203
+ /** Load a lazy group's leaf rows on expand (returns a promise; shows a loading
204
+ row, then caches). Required with `lazyGroups`. */
205
+ loadGroup?: (key: string) => Promise<GridRow[]>;
182
206
  /** Enable drag-to-reorder rows via a handle in the first column. Called with
183
207
  the from/to indices (into the visible rows) on drop — reorder your own
184
208
  `rows` in here. Flat, unsorted, in-memory lists only. */
@@ -375,6 +399,70 @@
375
399
  expVersion++;
376
400
  }
377
401
 
402
+ // Async tree children: cache loaded children by row id; track in-flight loads.
403
+ const childrenCache = new Map<string | number, GridRow[]>();
404
+ const loadingIds = new Set<string | number>();
405
+ let treeVersion = $state(0);
406
+
407
+ // Expand/collapse a tree node, lazy-loading children on first expand (async).
408
+ function toggleTreeNode(row: GridRow): void {
409
+ const id = getRowId(row);
410
+ if (expandedRows.has(id)) {
411
+ expandedRows.delete(id);
412
+ expVersion++;
413
+ return;
414
+ }
415
+ expandedRows.add(id);
416
+ expVersion++;
417
+ if (loadChildren && !childrenCache.has(id) && !loadingIds.has(id)) {
418
+ loadingIds.add(id);
419
+ treeVersion++;
420
+ Promise.resolve(loadChildren(row)).then(
421
+ (kids) => {
422
+ childrenCache.set(id, kids);
423
+ loadingIds.delete(id);
424
+ treeVersion++;
425
+ },
426
+ () => {
427
+ // On failure, collapse so the user can retry by expanding again.
428
+ loadingIds.delete(id);
429
+ expandedRows.delete(id);
430
+ expVersion++;
431
+ treeVersion++;
432
+ },
433
+ );
434
+ }
435
+ }
436
+
437
+ // Expand/collapse a server-side group, lazy-loading its rows on first expand.
438
+ // Reuses the tree's children cache + loading machinery, keyed by group key.
439
+ function toggleLazyGroup(key: string): void {
440
+ if (expandedRows.has(key)) {
441
+ expandedRows.delete(key);
442
+ expVersion++;
443
+ return;
444
+ }
445
+ expandedRows.add(key);
446
+ expVersion++;
447
+ if (loadGroup && !childrenCache.has(key) && !loadingIds.has(key)) {
448
+ loadingIds.add(key);
449
+ treeVersion++;
450
+ Promise.resolve(loadGroup(key)).then(
451
+ (rows) => {
452
+ childrenCache.set(key, rows);
453
+ loadingIds.delete(key);
454
+ treeVersion++;
455
+ },
456
+ () => {
457
+ loadingIds.delete(key);
458
+ expandedRows.delete(key);
459
+ expVersion++;
460
+ treeVersion++;
461
+ },
462
+ );
463
+ }
464
+ }
465
+
378
466
  function isRowSelected(id: string | number): boolean {
379
467
  selRowsVersion; // track
380
468
  return selectedRows.has(id);
@@ -413,6 +501,33 @@
413
501
  const layout = $derived(arrangePinned(pinnedSized));
414
502
  const cols = $derived(layout.columns);
415
503
  const pinned = $derived(layout.anyPinned);
504
+ // Fixed-width horizontal-scroll mode: when columns are pinned OR column
505
+ // virtualization is on. Drives the same layout (explicit widths, overflow-x,
506
+ // scroll-synced header) that pinning already uses.
507
+ const hScroll = $derived(pinned || virtualizeColumns);
508
+
509
+ // Column virtualization: render only the columns whose x-range intersects the
510
+ // horizontal viewport (+ overscan); pinned columns always render. Off-window
511
+ // runs of columns collapse into a single spacer so widths/positions are exact.
512
+ const COL_OVERSCAN = 320; // px
513
+ let scrollLeft = $state(0);
514
+ let viewW = $state(0);
515
+ type ColItem = { kind: 'cell'; ci: number; key: string } | { kind: 'spacer'; w: number; key: string };
516
+ const colItems = $derived.by<ColItem[]>(() => {
517
+ const n = cols.length;
518
+ // Off, or before the viewport width is measured: render every column.
519
+ if (!virtualizeColumns || !viewW) {
520
+ return Array.from({ length: n }, (_, ci) => ({ kind: 'cell', ci, key: 'c' + ci }) as ColItem);
521
+ }
522
+ const widths = layout.info.map((inf) => inf.width);
523
+ const pins = layout.info.map((inf) => inf.pinned);
524
+ let sp = 0;
525
+ return columnWindow(columnOffsets(widths), widths, pins, scrollLeft, viewW, COL_OVERSCAN).map((it) =>
526
+ it.kind === 'cell'
527
+ ? ({ kind: 'cell', ci: it.ci, key: 'c' + it.ci } as ColItem)
528
+ : ({ kind: 'spacer', w: it.w, key: 's' + sp++ } as ColItem),
529
+ );
530
+ });
416
531
 
417
532
  // Spanning header groups: consecutive columns sharing a `group` label merge
418
533
  // into one parent header cell (width = sum of child widths). null when unused.
@@ -448,14 +563,14 @@
448
563
  return `position:sticky;left:${inf.left + leadPx}px;`;
449
564
  }
450
565
  function headStyle(ci: number): string {
451
- if (!pinned) return colStyle(cols[ci]);
566
+ if (!hScroll) return colStyle(cols[ci]);
452
567
  const inf = layout.info[ci];
453
568
  let s = `flex:0 0 ${inf.width}px;width:${inf.width}px;`;
454
569
  if (inf.pinned) s += `${pinStick(ci)}z-index:5;background:var(--bo-header-bg);`;
455
570
  return s;
456
571
  }
457
572
  function cellWidthStyle(ci: number): string {
458
- if (!pinned) return colStyle(cols[ci]);
573
+ if (!hScroll) return colStyle(cols[ci]);
459
574
  const inf = layout.info[ci];
460
575
  let s = `flex:0 0 ${inf.width}px;width:${inf.width}px;`;
461
576
  if (inf.pinned) s += `${pinStick(ci)}z-index:1;background:var(--bo-bg);`;
@@ -465,14 +580,14 @@
465
580
  // expand column, if any) when the grid scrolls horizontally (pinned mode).
466
581
  function selCellStyle(header: boolean): string {
467
582
  let s = `flex:0 0 ${SEL_W}px;width:${SEL_W}px;`;
468
- if (pinned)
583
+ if (hScroll)
469
584
  s += `position:sticky;left:${expOffset * EXP_W}px;z-index:${header ? 6 : 2};background:var(--bo-${header ? 'header-bg' : 'bg'});`;
470
585
  return s;
471
586
  }
472
587
  // The leading expand-toggle column (master-detail), sticky at the far left.
473
588
  function expandCellStyle(header: boolean): string {
474
589
  let s = `flex:0 0 ${EXP_W}px;width:${EXP_W}px;`;
475
- if (pinned)
590
+ if (hScroll)
476
591
  s += `position:sticky;left:0;z-index:${header ? 6 : 2};background:var(--bo-${header ? 'header-bg' : 'bg'});`;
477
592
  return s;
478
593
  }
@@ -724,6 +839,14 @@
724
839
  }
725
840
 
726
841
  // In-memory pipeline (skipped entirely in source mode).
842
+ // Resolve a column's value by key (computed-aware) — for filtering and the
843
+ // set-filter distinct list. Falls back to the raw row field for plain columns.
844
+ const colByKey = $derived(new Map(columns.map((c) => [c.key, c] as const)));
845
+ const valueOf = (row: GridRow, key: string): unknown => {
846
+ const c = colByKey.get(key);
847
+ return c ? cellValue(c, row) : row[key];
848
+ };
849
+
727
850
  const view = $derived.by(() => {
728
851
  if (source) return [] as GridRow[];
729
852
  const base = rows;
@@ -741,10 +864,10 @@
741
864
  const hasColFilters = Object.keys(active).length > 0;
742
865
  return untrack(() => {
743
866
  let r = base;
744
- if (f) r = r.filter((row) => allCols.some((c) => String(row[c.key] ?? '').toLowerCase().includes(f)));
745
- if (q) r = r.filter((row) => allCols.some((c) => String(row[c.key] ?? '').toLowerCase().includes(q)));
867
+ if (f) r = r.filter((row) => allCols.some((c) => String(cellValue(c, row) ?? '').toLowerCase().includes(f)));
868
+ if (q) r = r.filter((row) => allCols.some((c) => String(cellValue(c, row) ?? '').toLowerCase().includes(q)));
746
869
  if (hasColFilters) {
747
- r = r.filter((row) => passesFilters(row, active));
870
+ r = r.filter((row) => passesFilters(row, active, valueOf));
748
871
  }
749
872
  if (s.length > 0) {
750
873
  const colOf = (k: string) => allCols.find((c) => c.key === k);
@@ -754,6 +877,30 @@
754
877
  });
755
878
  });
756
879
 
880
+ // Conditional-formatting ranges: the data extent (min/max over the current
881
+ // view) for each column with a data bar or colour scale. Per-column `min`/`max`
882
+ // config overrides are applied later, in the cell. Keyed by column key.
883
+ const cfRanges = $derived.by<Record<string, { min: number; max: number }>>(() => {
884
+ const out: Record<string, { min: number; max: number }> = {};
885
+ for (const col of columns) {
886
+ if (!col.dataBar && !col.colorScale) continue;
887
+ let lo = Infinity;
888
+ let hi = -Infinity;
889
+ for (const row of view) {
890
+ const raw = cellValue(col, row);
891
+ if (raw === null || raw === undefined || raw === '') continue; // blanks aren't 0
892
+ const n = Number(raw);
893
+ if (!Number.isFinite(n)) continue;
894
+ if (n < lo) lo = n;
895
+ if (n > hi) hi = n;
896
+ }
897
+ if (!Number.isFinite(lo)) lo = 0;
898
+ if (!Number.isFinite(hi)) hi = lo + 1;
899
+ out[col.key] = { min: lo, max: hi };
900
+ }
901
+ return out;
902
+ });
903
+
757
904
  // Pagination (in-memory only): slice the view into pages; rows still
758
905
  // virtualize within a page. Off when pageSize <= 0.
759
906
  const paged = $derived(pageSize > 0 && !source);
@@ -775,7 +922,9 @@
775
922
  paged ? view.slice(currentPage * pageSize, currentPage * pageSize + pageSize) : view,
776
923
  );
777
924
 
778
- const treeData = $derived(!!getChildren && !source);
925
+ const treeData = $derived(!!(getChildren || loadChildren) && !source);
926
+ // Server-side (lazy) grouping: group summaries up front, rows on expand.
927
+ const lazyGrouped = $derived(!!lazyGroups && !source && !treeData);
779
928
 
780
929
  // Drag-to-reorder rows (flat, unsorted, in-memory only). The handle lives in
781
930
  // the first cell; the dragged/drop indices are tracked in component state.
@@ -793,11 +942,29 @@
793
942
  }
794
943
 
795
944
  const flat = $derived.by<VisualRow[]>(() => {
796
- if (treeData && getChildren) {
945
+ if (treeData) {
797
946
  const roots = rows;
798
947
  expVersion; // track expand/collapse
948
+ treeVersion; // track async child loads
799
949
  return untrack(() =>
800
- buildTreeRows(roots, getChildren, (r) => expandedRows.has(getRowId(r))),
950
+ buildTreeRows(roots, {
951
+ childrenOf: (r) => (loadChildren ? childrenCache.get(getRowId(r)) : getChildren?.(r)),
952
+ hasChildren: hasChildren ?? ((r) => !!getChildren?.(r)?.length),
953
+ isExpanded: (r) => expandedRows.has(getRowId(r)),
954
+ isLoading: (r) => loadingIds.has(getRowId(r)),
955
+ }),
956
+ );
957
+ }
958
+ if (lazyGrouped && lazyGroups) {
959
+ const groups = lazyGroups;
960
+ expVersion; // track expand/collapse
961
+ treeVersion; // track async row loads
962
+ return untrack(() =>
963
+ buildLazyGroupRows(groups, {
964
+ isExpanded: (key) => expandedRows.has(key),
965
+ rowsOf: (key) => childrenCache.get(key),
966
+ isLoading: (key) => loadingIds.has(key),
967
+ }),
801
968
  );
802
969
  }
803
970
  const v = pageRows;
@@ -846,7 +1013,7 @@
846
1013
  if (col.type === 'sparkline' || col.type === 'text' || col.type === 'custom' || !col.groupAgg) return '';
847
1014
  const vals: number[] = [];
848
1015
  for (const row of v) {
849
- const n = Number(row[col.key]);
1016
+ const n = Number(cellValue(col, row));
850
1017
  if (Number.isFinite(n)) vals.push(n);
851
1018
  }
852
1019
  const a = aggregate(vals);
@@ -890,7 +1057,7 @@
890
1057
 
891
1058
  const total = $derived(hm.total);
892
1059
  const rowWidthStyle = $derived(
893
- pinned ? `width:${layout.totalWidth + leadPx}px;right:auto;` : '',
1060
+ hScroll ? `width:${layout.totalWidth + leadPx}px;right:auto;` : '',
894
1061
  );
895
1062
  const visibleCount = $derived(Math.ceil(height / baseH) + OVERSCAN * 2);
896
1063
  const start = $derived(Math.max(0, hm.indexAt(scrollTop) - OVERSCAN));
@@ -903,6 +1070,7 @@
903
1070
  type RenderItem =
904
1071
  | { vr: number; kind: 'group'; group: GroupNode }
905
1072
  | { vr: number; kind: 'data'; row: GridRow; depth?: number; hasChildren?: boolean }
1073
+ | { vr: number; kind: 'treeloading'; depth: number }
906
1074
  | { vr: number; kind: 'skeleton' };
907
1075
 
908
1076
  const renderItems = $derived.by<RenderItem[]>(() => {
@@ -918,6 +1086,7 @@
918
1086
  const item = flat[vr];
919
1087
  if (!item) continue;
920
1088
  if (item.kind === 'group') out.push({ vr, kind: 'group', group: item.group });
1089
+ else if (item.kind === 'treeloading') out.push({ vr, kind: 'treeloading', depth: item.depth });
921
1090
  else out.push({ vr, kind: 'data', row: item.row, depth: item.depth, hasChildren: item.hasChildren });
922
1091
  }
923
1092
  }
@@ -956,7 +1125,7 @@
956
1125
  if (!row) continue;
957
1126
  for (let c = b.c0; c <= cEnd; c++) {
958
1127
  if (!isNumeric(cols[c])) continue;
959
- const v = Number(row[cols[c].key]);
1128
+ const v = Number(cellValue(cols[c], row));
960
1129
  if (Number.isFinite(v)) vals.push(v);
961
1130
  }
962
1131
  }
@@ -966,9 +1135,10 @@
966
1135
  function onScroll(e: Event) {
967
1136
  const el = e.currentTarget as HTMLElement;
968
1137
  scrollTop = el.scrollTop;
969
- if (pinned && headEl) headEl.scrollLeft = el.scrollLeft; // keep header in sync
970
- if (pinned && filterRowEl) filterRowEl.scrollLeft = el.scrollLeft; // and the filter row
971
- if (pinned && groupHeadEl) groupHeadEl.scrollLeft = el.scrollLeft; // and group headers
1138
+ if (hScroll) scrollLeft = el.scrollLeft; // drives column virtualization
1139
+ if (hScroll && headEl) headEl.scrollLeft = el.scrollLeft; // keep header in sync
1140
+ if (hScroll && filterRowEl) filterRowEl.scrollLeft = el.scrollLeft; // and the filter row
1141
+ if (hScroll && groupHeadEl) groupHeadEl.scrollLeft = el.scrollLeft; // and group headers
972
1142
  }
973
1143
 
974
1144
  function toggleGroup(path: string) {
@@ -1078,17 +1248,14 @@
1078
1248
  function filterKindFor(col: ColumnDef): FilterKind {
1079
1249
  return typeof col.filter === 'string' ? col.filter : defaultFilterKind(col);
1080
1250
  }
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();
1251
+ async function openFilterMenu(col: ColumnDef, anchor: { left: number; bottom: number }) {
1085
1252
  let kind = filterKindFor(col);
1086
1253
  // A set filter needs distinct values; in source mode they can't be
1087
1254
  // enumerated, so fall back to the column's typed filter.
1088
1255
  if (source && kind === 'set') kind = defaultFilterKind(col);
1089
- const values = !source && kind === 'set' ? distinctValues(rows, col.key) : [];
1256
+ const values = !source && kind === 'set' ? distinctValues(rows, col.key, valueOf) : [];
1090
1257
  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 };
1258
+ filterUi = { key: col.key, kind, header: col.header, values, x: anchor.left, y: anchor.bottom + 2 };
1092
1259
  }
1093
1260
  function applyColumnFilter(key: string, f: ColumnFilter | null): void {
1094
1261
  const next = { ...activeColumnFilters };
@@ -1120,7 +1287,7 @@
1120
1287
  let maxLen = col.header.length;
1121
1288
  if (!source) {
1122
1289
  for (const row of view.slice(0, 500)) {
1123
- const s = formatCell(col, row[col.key], row);
1290
+ const s = formatCell(col, cellValue(col, row), row);
1124
1291
  if (s.length > maxLen) maxLen = s.length;
1125
1292
  }
1126
1293
  }
@@ -1132,7 +1299,11 @@
1132
1299
 
1133
1300
  // Column header menu (⋮): sort / pin / autosize / hide. Reuses the floating
1134
1301
  // RowMenu (a light action list — no lazy chunk, unlike the filter menu).
1135
- function columnMenuItems(col: ColumnDef): Array<{ label: string; onSelect: () => void }> {
1302
+ // `anchor` positions the (keyboard-reachable) Filter submenu at the column.
1303
+ function columnMenuItems(
1304
+ col: ColumnDef,
1305
+ anchor: { left: number; bottom: number },
1306
+ ): Array<{ label: string; onSelect: () => void }> {
1136
1307
  const items: Array<{ label: string; onSelect: () => void }> = [];
1137
1308
  if (isSortable(col)) {
1138
1309
  items.push({ label: 'Sort ascending', onSelect: () => setSorts([{ key: col.key, dir: 'asc' }]) });
@@ -1141,6 +1312,10 @@
1141
1312
  items.push({ label: 'Clear sort', onSelect: () => setSorts(sorts.filter((s) => s.key !== col.key)) });
1142
1313
  }
1143
1314
  }
1315
+ // Keyboard path to filtering (the header funnel is pointer-only by design).
1316
+ if (filterMenu && col.type !== 'sparkline' && col.filter !== false) {
1317
+ items.push({ label: 'Filter…', onSelect: () => void openFilterMenu(col, anchor) });
1318
+ }
1144
1319
  const side = pinSideOf(col);
1145
1320
  if (side !== 'left') items.push({ label: 'Pin left', onSelect: () => setPinOverride(col.key, 'left') });
1146
1321
  if (side !== 'right') items.push({ label: 'Pin right', onSelect: () => setPinOverride(col.key, 'right') });
@@ -1152,7 +1327,8 @@
1152
1327
  function openColumnMenu(col: ColumnDef, e: Event) {
1153
1328
  e.stopPropagation();
1154
1329
  const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
1155
- menu = { x: rect.left, y: rect.bottom + 2, items: columnMenuItems(col) };
1330
+ const anchor = { left: rect.left, bottom: rect.bottom };
1331
+ menu = { x: rect.left, y: rect.bottom + 2, items: columnMenuItems(col, anchor) };
1156
1332
  }
1157
1333
 
1158
1334
  function onCellClicked(r: number, c: number, e: MouseEvent) {
@@ -1160,7 +1336,7 @@
1160
1336
  const row = dataAt(r);
1161
1337
  if (!row) return;
1162
1338
  const column = cols[c];
1163
- onCellClick({ row, column, value: row[column.key] }, e);
1339
+ onCellClick({ row, column, value: cellValue(column, row) }, e);
1164
1340
  }
1165
1341
 
1166
1342
  // Move the focus to an absolute (r, c), clamped; extend the selection if asked.
@@ -1193,7 +1369,7 @@
1193
1369
  const cells: string[] = [];
1194
1370
  for (let c = b.c0; c <= cEnd; c++) {
1195
1371
  const col = cols[c];
1196
- cells.push(col.type === 'sparkline' || col.type === 'custom' ? '' : formatCell(col, row[col.key], row));
1372
+ cells.push(col.type === 'sparkline' || col.type === 'custom' ? '' : formatCell(col, cellValue(col, row), row));
1197
1373
  }
1198
1374
  lines.push(cells.join('\t'));
1199
1375
  }
@@ -1337,7 +1513,8 @@
1337
1513
  if (columnMenu && sel.focus && e.altKey && e.key === 'ArrowDown') {
1338
1514
  e.preventDefault();
1339
1515
  const rect = document.getElementById(activeId ?? '')?.getBoundingClientRect();
1340
- menu = { x: rect?.left ?? 0, y: rect?.bottom ?? 0, items: columnMenuItems(cols[sel.focus.c]) };
1516
+ const anchor = { left: rect?.left ?? 0, bottom: rect?.bottom ?? 0 };
1517
+ menu = { x: anchor.left, y: anchor.bottom, items: columnMenuItems(cols[sel.focus.c], anchor) };
1341
1518
  return;
1342
1519
  }
1343
1520
  // Tree nav: ArrowRight expands a collapsed node, ArrowLeft collapses an
@@ -1349,12 +1526,12 @@
1349
1526
  const open = expandedRows.has(id);
1350
1527
  if (e.key === 'ArrowRight' && !open) {
1351
1528
  e.preventDefault();
1352
- toggleExpand(id);
1529
+ toggleTreeNode(item.row);
1353
1530
  return;
1354
1531
  }
1355
1532
  if (e.key === 'ArrowLeft' && open) {
1356
1533
  e.preventDefault();
1357
- toggleExpand(id);
1534
+ toggleTreeNode(item.row);
1358
1535
  return;
1359
1536
  }
1360
1537
  }
@@ -1438,7 +1615,7 @@
1438
1615
  </div>
1439
1616
  {/if}
1440
1617
  {#if headerGroups}
1441
- <div class="head-groups" aria-hidden="true" bind:this={groupHeadEl} style={pinned ? 'overflow:hidden;' : ''}>
1618
+ <div class="head-groups" aria-hidden="true" bind:this={groupHeadEl} style={hScroll ? 'overflow:hidden;' : ''}>
1442
1619
  {#if expandable}<span class="expandcell" style={expandCellStyle(true)}></span>{/if}
1443
1620
  {#if rowSelection}<span class="selcell" style={selCellStyle(true)}></span>{/if}
1444
1621
  {#each headerGroups as g, gi (gi)}
@@ -1446,7 +1623,7 @@
1446
1623
  {/each}
1447
1624
  </div>
1448
1625
  {/if}
1449
- <div class="head" role="row" aria-rowindex={1} bind:this={headEl} style={pinned ? 'overflow:hidden;' : ''}>
1626
+ <div class="head" role="row" aria-rowindex={1} bind:this={headEl} style={hScroll ? 'overflow:hidden;' : ''}>
1450
1627
  {#if expandable}
1451
1628
  <span class="expandcell selhead" role="columnheader" aria-colindex={1} style={expandCellStyle(true)}></span>
1452
1629
  {/if}
@@ -1512,6 +1689,9 @@
1512
1689
  </span>
1513
1690
  {/if}
1514
1691
  {#if filterMenu && col.type !== 'sparkline' && col.filter !== false}
1692
+ <!-- Pointer affordance only (tabindex=-1, APG grid pattern): keyboard
1693
+ users reach filtering via the column menu's "Filter…" item. -->
1694
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
1515
1695
  <span
1516
1696
  class="funnel"
1517
1697
  class:on={isFilterActive(activeColumnFilters[col.key])}
@@ -1519,9 +1699,9 @@
1519
1699
  tabindex="-1"
1520
1700
  aria-label="Filter {col.header}"
1521
1701
  title="Filter {col.header}"
1522
- onclick={(e) => openFilterMenu(col, e)}
1523
- onkeydown={(e) => {
1524
- if (e.key === 'Enter' || e.key === ' ') openFilterMenu(col, e);
1702
+ onclick={(e) => {
1703
+ e.stopPropagation();
1704
+ openFilterMenu(col, (e.currentTarget as HTMLElement).getBoundingClientRect());
1525
1705
  }}
1526
1706
  ondragstart={(e) => e.preventDefault()}
1527
1707
  draggable="false"
@@ -1562,7 +1742,7 @@
1562
1742
  </div>
1563
1743
 
1564
1744
  {#if filterRow && !source}
1565
- <div class="filter-row" role="row" bind:this={filterRowEl} style={pinned ? 'overflow:hidden;' : ''}>
1745
+ <div class="filter-row" role="row" bind:this={filterRowEl} style={hScroll ? 'overflow:hidden;' : ''}>
1566
1746
  {#if expandable}<span class="expandcell" style={expandCellStyle(false)}></span>{/if}
1567
1747
  {#if rowSelection}<span class="selcell" style={selCellStyle(false)}></span>{/if}
1568
1748
  {#each cols as col, ci (ci)}
@@ -1584,8 +1764,9 @@
1584
1764
 
1585
1765
  <div
1586
1766
  class="viewport"
1587
- style="height:{height}px;{pinned ? 'overflow-x:auto;' : ''}"
1767
+ style="height:{height}px;{hScroll ? 'overflow-x:auto;' : ''}"
1588
1768
  bind:this={viewportEl}
1769
+ bind:clientWidth={viewW}
1589
1770
  onscroll={onScroll}
1590
1771
  >
1591
1772
  {#if pinnedRows.length > 0}
@@ -1606,7 +1787,7 @@
1606
1787
  pinned={pinned && layout.info[ci].pinned}
1607
1788
  pinSide={layout.info[ci].side ?? 'left'}
1608
1789
  pinOffset={layout.info[ci].side === 'right' ? layout.info[ci].right : layout.info[ci].left + leadPx}
1609
- width={pinned ? layout.info[ci].width : undefined}
1790
+ width={hScroll ? layout.info[ci].width : undefined}
1610
1791
  />
1611
1792
  {/each}
1612
1793
  </div>
@@ -1631,13 +1812,13 @@
1631
1812
  {/each}
1632
1813
  </div>
1633
1814
  {/if}
1634
- <div class="spacer" style="height:{total}px;{pinned ? `width:${layout.totalWidth + leadPx}px;` : ''}">
1815
+ <div class="spacer" style="height:{total}px;{hScroll ? `width:${layout.totalWidth + leadPx}px;` : ''}">
1635
1816
  {#each renderItems as item (item.vr)}
1636
1817
  {#if item.kind === 'group'}
1637
1818
  <div class="grouprow" style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}">
1638
1819
  {#if expandable}<span class="expandcell" aria-hidden="true" style={expandCellStyle(false)}></span>{/if}
1639
1820
  {#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
1640
- <GroupRow group={item.group} columns={cols} onToggle={toggleGroup} rowIndex={item.vr + 2} />
1821
+ <GroupRow group={item.group} columns={cols} onToggle={lazyGrouped ? toggleLazyGroup : toggleGroup} rowIndex={item.vr + 2} />
1641
1822
  </div>
1642
1823
  {:else if item.kind === 'skeleton'}
1643
1824
  <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}">
@@ -1647,6 +1828,14 @@
1647
1828
  <span class="c" style={cellWidthStyle(ci)}><span class="skelbar"></span></span>
1648
1829
  {/each}
1649
1830
  </div>
1831
+ {:else if item.kind === 'treeloading'}
1832
+ <div class="row treeloading" role="row" aria-rowindex={item.vr + 2} aria-live="polite" style="top:{hm.offsetOf(item.vr)}px;height:{hm.heightOf(item.vr)}px;{rowWidthStyle}">
1833
+ {#if expandable}<span class="expandcell" aria-hidden="true" style={expandCellStyle(false)}></span>{/if}
1834
+ {#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
1835
+ <span class="tree-loading-cell" style="padding-left:{(item.depth ?? 0) * 16 + 24}px">
1836
+ <span class="spinner sm" aria-hidden="true"></span>Loading…
1837
+ </span>
1838
+ </div>
1650
1839
  {:else}
1651
1840
  <!-- Row activation is keyboard-accessible at the grid level: Enter on the focused cell fires onRowClick (focus is via aria-activedescendant). -->
1652
1841
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
@@ -1681,48 +1870,55 @@
1681
1870
  />
1682
1871
  </span>
1683
1872
  {/if}
1684
- {#each cols as col, ci (ci)}
1685
- <Cell
1686
- {col}
1687
- row={item.row}
1688
- r={item.vr}
1689
- c={ci}
1690
- colIndex={ci + 1 + leadCols}
1691
- cellId={`${gid}-r${item.vr}-c${ci}`}
1692
- cellSnippet={cell}
1693
- selected={sel.contains(item.vr, ci)}
1694
- focused={sel.isFocus(item.vr, ci)}
1695
- pinned={pinned && layout.info[ci].pinned}
1696
- pinSide={layout.info[ci].side ?? 'left'}
1697
- pinOffset={layout.info[ci].side === 'right' ? layout.info[ci].right : layout.info[ci].left + leadPx}
1698
- width={pinned ? layout.info[ci].width : undefined}
1699
- alt={item.vr % 2 === 1}
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}
1716
- {onCellDown}
1717
- {onCellEnter}
1718
- onCellClick={onCellClick ? onCellClicked : undefined}
1719
- onCellDblClick={startEdit}
1720
- onEditCommit={(raw) => commitEdit(item.vr, ci, raw)}
1721
- onEditCancel={() => {
1722
- editing = null;
1723
- editSeed = null;
1724
- }}
1725
- />
1873
+ {#each colItems as it (it.key)}
1874
+ {#if it.kind === 'cell'}
1875
+ {@const ci = it.ci}
1876
+ {@const col = cols[ci]}
1877
+ <Cell
1878
+ {col}
1879
+ row={item.row}
1880
+ r={item.vr}
1881
+ c={ci}
1882
+ colIndex={ci + 1 + leadCols}
1883
+ cellId={`${gid}-r${item.vr}-c${ci}`}
1884
+ cellSnippet={cell}
1885
+ selected={sel.contains(item.vr, ci)}
1886
+ focused={sel.isFocus(item.vr, ci)}
1887
+ pinned={pinned && layout.info[ci].pinned}
1888
+ pinSide={layout.info[ci].side ?? 'left'}
1889
+ pinOffset={layout.info[ci].side === 'right' ? layout.info[ci].right : layout.info[ci].left + leadPx}
1890
+ width={hScroll ? layout.info[ci].width : undefined}
1891
+ alt={item.vr % 2 === 1}
1892
+ editing={editing?.r === item.vr && editing?.c === ci}
1893
+ seed={editing?.r === item.vr && editing?.c === ci ? editSeed : null}
1894
+ fillCorner={fillHandle && !!sel.bounds && item.vr === sel.bounds.r1 && ci === sel.bounds.c1}
1895
+ fillpreview={inFillPreview(item.vr, ci)}
1896
+ cfRange={col.dataBar || col.colorScale ? cfRanges[col.key] : null}
1897
+ {onFillStart}
1898
+ tree={treeData && ci === 0
1899
+ ? {
1900
+ depth: item.depth ?? 0,
1901
+ hasChildren: item.hasChildren ?? false,
1902
+ expanded: isExpanded(getRowId(item.row)),
1903
+ onToggle: () => toggleTreeNode(item.row),
1904
+ }
1905
+ : undefined}
1906
+ dragHandle={reorderable && ci === 0
1907
+ ? { onStart: () => (dragRowVr = item.vr), onEnd: () => (dragRowVr = -1) }
1908
+ : undefined}
1909
+ {onCellDown}
1910
+ {onCellEnter}
1911
+ onCellClick={onCellClick ? onCellClicked : undefined}
1912
+ onCellDblClick={startEdit}
1913
+ onEditCommit={(raw) => commitEdit(item.vr, ci, raw)}
1914
+ onEditCancel={() => {
1915
+ editing = null;
1916
+ editSeed = null;
1917
+ }}
1918
+ />
1919
+ {:else}
1920
+ <span class="colspacer" aria-hidden="true" style="flex:0 0 {it.w}px;width:{it.w}px;"></span>
1921
+ {/if}
1726
1922
  {/each}
1727
1923
  </div>
1728
1924
  {#if expandable && detail && isExpanded(getRowId(item.row))}
@@ -1734,7 +1930,7 @@
1734
1930
  {/each}
1735
1931
  </div>
1736
1932
  {#if footerCells}
1737
- <div class="footer" role="row" style={pinned ? `width:${layout.totalWidth + leadPx}px;` : ''}>
1933
+ <div class="footer" role="row" style={hScroll ? `width:${layout.totalWidth + leadPx}px;` : ''}>
1738
1934
  {#if expandable}<span class="expandcell" aria-hidden="true" style={expandCellStyle(false)}></span>{/if}
1739
1935
  {#if rowSelection}<span class="selcell" aria-hidden="true" style={selCellStyle(false)}></span>{/if}
1740
1936
  {#each cols as col, ci (ci)}
@@ -1803,6 +1999,15 @@
1803
1999
  --bo-header-h: var(--bo-grid-header-h, 28px);
1804
2000
  --bo-mono: var(--bo-grid-mono, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace);
1805
2001
  --bo-sans: var(--bo-grid-sans, Inter, "Segoe UI", system-ui, sans-serif);
2002
+ /* Layout/density tokens (override via --bo-grid-* for a custom look). */
2003
+ --bo-radius: var(--bo-grid-radius, 8px);
2004
+ --bo-font-size: var(--bo-grid-font-size, 13px);
2005
+ --bo-cell-pad: var(--bo-grid-cell-pad, 8px);
2006
+
2007
+ /* Theme native controls (checkboxes, date pickers, number spinners, search
2008
+ clear buttons, scrollbars) and accent fills to match the grid theme. */
2009
+ color-scheme: var(--bo-grid-scheme, dark);
2010
+ accent-color: var(--bo-up);
1806
2011
 
1807
2012
  display: flex;
1808
2013
  flex-direction: column;
@@ -1810,7 +2015,7 @@
1810
2015
  font-family: var(--bo-sans);
1811
2016
  background: var(--bo-bg);
1812
2017
  border: 0.5px solid var(--bo-border);
1813
- border-radius: 8px;
2018
+ border-radius: var(--bo-radius);
1814
2019
  overflow: hidden;
1815
2020
  outline: none;
1816
2021
  }
@@ -1893,7 +2098,7 @@
1893
2098
  display: flex;
1894
2099
  align-items: center;
1895
2100
  gap: 4px;
1896
- padding: 0 8px;
2101
+ padding: 0 var(--bo-cell-pad, 8px);
1897
2102
  min-width: 0;
1898
2103
  font: inherit;
1899
2104
  font-size: 11px;
@@ -1904,6 +2109,15 @@
1904
2109
  border: 0;
1905
2110
  cursor: grab;
1906
2111
  }
2112
+ /* Visible keyboard focus (WCAG 2.4.7) for the grid's tabbable controls. */
2113
+ .h:focus-visible,
2114
+ .bo-cols-toggle:focus-visible,
2115
+ .expand-toggle:focus-visible,
2116
+ .bo-quickfilter:focus-visible {
2117
+ outline: 2px solid var(--bo-sel-border);
2118
+ outline-offset: -2px;
2119
+ color: var(--bo-text);
2120
+ }
1907
2121
  /* Drag-to-resize grip: a thin hit-target straddling the column's right edge. */
1908
2122
  .h .grip {
1909
2123
  position: absolute;
@@ -2037,6 +2251,26 @@
2037
2251
  overflow-y: auto;
2038
2252
  overflow-x: hidden;
2039
2253
  user-select: none;
2254
+ /* Thin, themed scrollbars (Firefox) — Chromium/Safari below. */
2255
+ scrollbar-width: thin;
2256
+ scrollbar-color: color-mix(in srgb, var(--bo-text-dim) 55%, transparent) transparent;
2257
+ }
2258
+ .viewport::-webkit-scrollbar {
2259
+ width: 10px;
2260
+ height: 10px;
2261
+ }
2262
+ .viewport::-webkit-scrollbar-thumb {
2263
+ background: color-mix(in srgb, var(--bo-text-dim) 45%, transparent);
2264
+ border: 2px solid transparent;
2265
+ background-clip: padding-box;
2266
+ border-radius: 6px;
2267
+ }
2268
+ .viewport::-webkit-scrollbar-thumb:hover {
2269
+ background: color-mix(in srgb, var(--bo-text-dim) 70%, transparent);
2270
+ background-clip: padding-box;
2271
+ }
2272
+ .viewport::-webkit-scrollbar-corner {
2273
+ background: transparent;
2040
2274
  }
2041
2275
  .empty {
2042
2276
  position: absolute;
@@ -2081,6 +2315,23 @@
2081
2315
  animation: none;
2082
2316
  }
2083
2317
  }
2318
+ .spinner.sm {
2319
+ width: 12px;
2320
+ height: 12px;
2321
+ border-width: 1.5px;
2322
+ }
2323
+ /* Async tree: placeholder row shown while a node's children load. */
2324
+ .row.treeloading {
2325
+ align-items: center;
2326
+ }
2327
+ .tree-loading-cell {
2328
+ display: inline-flex;
2329
+ align-items: center;
2330
+ gap: 8px;
2331
+ font-size: var(--bo-font-size, 13px);
2332
+ color: var(--bo-text-dim);
2333
+ font-style: italic;
2334
+ }
2084
2335
  .spacer {
2085
2336
  position: relative;
2086
2337
  width: 100%;