bo-grid 0.21.0 → 1.0.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.
@@ -0,0 +1,17 @@
1
+ // Type declarations for the `bo-grid/element` entry (the <bo-grid> custom element).
2
+ import type { GridProps } from './index';
3
+
4
+ /** The <bo-grid> `config` object — every <Grid> prop. */
5
+ export type BoGridConfig = GridProps;
6
+
7
+ /** A <bo-grid> DOM element with a typed `config` property. */
8
+ export interface BoGridElement extends HTMLElement {
9
+ config?: BoGridConfig;
10
+ }
11
+
12
+ /** Create a <bo-grid> element with its `config` already set (safe
13
+ * create-before-attach pattern for React/Vue/vanilla). */
14
+ export function createBoGrid(config?: BoGridConfig): BoGridElement;
15
+
16
+ declare const BoGrid: unknown;
17
+ export default BoGrid;
@@ -1,4 +1,5 @@
1
- import { E as f } from "./bo-grid.element-DPnHUXMa.js";
1
+ import { E as r, F as o } from "./bo-grid.element-BZGnfKB_.js";
2
2
  export {
3
- f as default
3
+ r as createBoGrid,
4
+ o as default
4
5
  };
@@ -3,6 +3,7 @@
3
3
  import type { ColumnDef, GridRow } from './column';
4
4
  import {
5
5
  formatCell,
6
+ tooltipText,
6
7
  colStyle,
7
8
  candlesOf,
8
9
  isNumeric,
@@ -146,12 +147,22 @@
146
147
  const extraClass = $derived(
147
148
  typeof col.cellClass === 'function' ? (col.cellClass(value, row) ?? '') : (col.cellClass ?? ''),
148
149
  );
149
- // Native tooltip of the full value (opt-in via column `tooltip`).
150
- const tip = $derived(
151
- col.tooltip && col.type !== 'sparkline' && col.type !== 'custom'
152
- ? formatCell(col, value, row)
153
- : undefined,
154
- );
150
+ // Styled floating tooltip text (opt-in via column `tooltip`); the grid root
151
+ // renders the actual tooltip from this cell's `data-bo-tip` attribute.
152
+ const tip = $derived(tooltipText(col, value, row));
153
+
154
+ // JS cell renderer (framework-agnostic alt to the `cell` snippet). Returns an
155
+ // HTML string ({@html}) or a DOM Node (mounted via the action below).
156
+ const rendered = $derived(col.render ? col.render({ value, row, column: col }) : undefined);
157
+ // Mount/replace a Node return value; updates when the derived node changes.
158
+ function renderNode(host: HTMLElement, node: Node | string | null | undefined) {
159
+ const set = (n: Node | string | null | undefined) => {
160
+ if (n instanceof Node) host.replaceChildren(n);
161
+ else host.textContent = n == null ? '' : String(n);
162
+ };
163
+ set(node);
164
+ return { update: set };
165
+ }
155
166
 
156
167
  // ---- Conditional formatting (v0.10): data bar + icon set ----
157
168
  // Geometry/threshold logic lives in column.ts (pure, unit-tested); here we map
@@ -193,6 +204,7 @@
193
204
  <span
194
205
  class="c {kind} {extraClass}"
195
206
  class:dim={col.type === 'volume'}
207
+ class:wrap={col.wrap}
196
208
  class:pos={col.type === 'percent' && Number(value) >= 0}
197
209
  class:neg={col.type === 'percent' && Number(value) < 0}
198
210
  class:sel={selected}
@@ -202,7 +214,7 @@
202
214
  role="gridcell"
203
215
  tabindex="-1"
204
216
  id={cellId}
205
- title={tip}
217
+ data-bo-tip={tip}
206
218
  aria-colindex={colIndex}
207
219
  aria-selected={selected}
208
220
  onpointerdown={(e) => onCellDown?.(r, c, e)}
@@ -269,6 +281,12 @@
269
281
  onclick={(e) => e.stopPropagation()}
270
282
  ondblclick={(e) => e.stopPropagation()}
271
283
  />
284
+ {:else if col.render}
285
+ {#if typeof rendered === 'string'}
286
+ <span class="bo-render">{@html rendered}</span>
287
+ {:else}
288
+ <span class="bo-render" use:renderNode={rendered}></span>
289
+ {/if}
272
290
  {:else if col.type === 'custom'}
273
291
  {#if cellSnippet}{@render cellSnippet({ row, column: col, value })}{:else}{value ?? ''}{/if}
274
292
  {:else if col.type === 'sparkline'}
@@ -324,10 +342,10 @@
324
342
  {/key}
325
343
  {:else if col.flash}
326
344
  {#key row.flashSeq}
327
- <span class="flash {row.flashDir}">{formatCell(col, value, row)}</span>
345
+ <span class="flash bo-cell-text {row.flashDir}">{formatCell(col, value, row)}</span>
328
346
  {/key}
329
347
  {:else}
330
- {formatCell(col, value, row)}
348
+ <span class="bo-cell-text">{formatCell(col, value, row)}</span>
331
349
  {/if}
332
350
  {#if fillCorner}
333
351
  <span
@@ -356,6 +374,35 @@
356
374
  white-space: nowrap;
357
375
  text-overflow: ellipsis;
358
376
  }
377
+ /* Truncating text node inside the flex cell: a bare text child of a flex
378
+ container won't honour text-overflow, so plain values render through this
379
+ span to get a real ellipsis when they overflow. */
380
+ .bo-cell-text {
381
+ min-width: 0;
382
+ overflow: hidden;
383
+ white-space: nowrap;
384
+ text-overflow: ellipsis;
385
+ }
386
+ /* Host for a JS `render` return (string HTML or a DOM node). Lays out inline
387
+ and truncates like a normal cell; consumer markup controls the rest. */
388
+ .bo-render {
389
+ display: inline-flex;
390
+ align-items: center;
391
+ gap: 5px;
392
+ min-width: 0;
393
+ overflow: hidden;
394
+ text-overflow: ellipsis;
395
+ }
396
+ /* Opt-in multi-line wrap (col.wrap) — pair with a taller rowHeight. */
397
+ .c.wrap {
398
+ white-space: normal;
399
+ }
400
+ .c.wrap .bo-cell-text,
401
+ .c.wrap strong {
402
+ white-space: normal;
403
+ overflow: visible;
404
+ text-overflow: clip;
405
+ }
359
406
  .num {
360
407
  justify-content: flex-end;
361
408
  font-family: var(--bo-mono);
@@ -500,10 +547,15 @@
500
547
  line-height: 1;
501
548
  }
502
549
  .text strong {
550
+ min-width: 0;
551
+ overflow: hidden;
552
+ white-space: nowrap;
553
+ text-overflow: ellipsis;
503
554
  font-family: var(--bo-mono);
504
555
  font-weight: 600;
505
556
  }
506
557
  .text em {
558
+ flex: none;
507
559
  font-style: normal;
508
560
  font-size: 10px;
509
561
  color: var(--bo-text-dim);
@@ -36,8 +36,8 @@
36
36
  import RowMenu from './RowMenu.svelte';
37
37
 
38
38
  let {
39
- rows,
40
- columns,
39
+ rows = [],
40
+ columns = [],
41
41
  height,
42
42
  filter = '',
43
43
  groupBy = [],
@@ -48,6 +48,7 @@
48
48
  rowHeight,
49
49
  theme,
50
50
  resizable = true,
51
+ cellSelection = true,
51
52
  rowSelection = false,
52
53
  onRowSelectionChange,
53
54
  hiddenColumns = [],
@@ -57,6 +58,7 @@
57
58
  virtualizeColumns = false,
58
59
  rowClass,
59
60
  getRowId = (r: GridRow) => r.id,
61
+ selectedRowId = null,
60
62
  onRowClick,
61
63
  sort,
62
64
  onSortChange,
@@ -81,6 +83,8 @@
81
83
  loadGroup,
82
84
  onRowReorder,
83
85
  pageSize = 0,
86
+ pageSizeOptions,
87
+ onPageSizeChange,
84
88
  page,
85
89
  onPageChange,
86
90
  onColumnReorder,
@@ -90,7 +94,12 @@
90
94
  }: {
91
95
  rows: GridRow[];
92
96
  columns: ColumnDef[];
93
- height: number;
97
+ /** Viewport height. A **number** is the scroll viewport's pixel height (the
98
+ grid's total height is that plus header/toolbar/footer chrome). A **CSS
99
+ string** (e.g. `'100%'`, `'80vh'`, `'480px'`) sizes the whole grid element
100
+ and the viewport auto-fits the space left after the chrome — give the grid
101
+ a sized parent (or use `'100%'` inside a flex/grid cell). */
102
+ height: number | string;
94
103
  /** Row height in px (uniform), or a function for variable heights
95
104
  (in-memory mode only). Default 36. */
96
105
  rowHeight?: number | ((row: GridRow, index: number) => number);
@@ -99,6 +108,11 @@
99
108
  /** Allow drag-to-resize column widths. Default true; opt out per column
100
109
  with `resizable: false`. */
101
110
  resizable?: boolean;
111
+ /** Highlight cells/ranges on click-drag (the blue selection fill + focus
112
+ ring + fill handle). Set false for a read-only/display grid where clicks
113
+ should pass straight through to `onRowClick`/`onCellClick` without a
114
+ selection highlight. Default true. */
115
+ cellSelection?: boolean;
102
116
  /** Show a leading checkbox column for whole-row selection (keyed by row id,
103
117
  stable across sort/filter). Default false. */
104
118
  rowSelection?: boolean;
@@ -128,6 +142,10 @@
128
142
  /** Identity key for row selection. Defaults to `row.id`; override for
129
143
  string/UUID/composite keys. */
130
144
  getRowId?: (row: GridRow) => string | number;
145
+ /** Controlled "active row" highlight (keyed by `getRowId`) — for
146
+ master-detail / list-detail where one row is selected. Independent of the
147
+ checkbox `rowSelection` and the cell range selection. `null` = none. */
148
+ selectedRowId?: string | number | null;
131
149
  /** Called when a data row is activated by click or Enter (open a detail
132
150
  view, navigate, …). Edit-input and checkbox clicks are excluded. */
133
151
  onRowClick?: (row: GridRow, event: MouseEvent | KeyboardEvent) => void;
@@ -210,6 +228,12 @@
210
228
  /** Rows per page. When > 0 (in-memory mode), shows a pager instead of one
211
229
  long scroll; rows still virtualize within a page. Default 0 (off). */
212
230
  pageSize?: number;
231
+ /** Page-size choices for a dropdown in the pager (e.g. `[25, 50, 100]`).
232
+ Picking one re-pages from page 0. Uncontrolled unless you also drive
233
+ `pageSize` and update it from `onPageSizeChange`. */
234
+ pageSizeOptions?: number[];
235
+ /** Called with the new page size when the pager's size dropdown changes. */
236
+ onPageSizeChange?: (size: number) => void;
213
237
  /** Controlled current page (0-based). Omit for uncontrolled paging. */
214
238
  page?: number;
215
239
  /** Called with the new page index when the pager is used. */
@@ -239,6 +263,11 @@
239
263
  const gid = `bo-grid-${uid++}`;
240
264
 
241
265
  let scrollTop = $state(0);
266
+ // Styled floating tooltip (opt-in via column `tooltip`): a single fixed-
267
+ // position bubble driven by the hovered cell's `data-bo-tip`. Fixed escapes
268
+ // the cell/viewport overflow:hidden without a portal.
269
+ const hasTooltips = $derived(columns.some((c) => !!c.tooltip || !!c.headerTooltip));
270
+ let tip = $state<{ x: number; y: number; text: string; below: boolean } | null>(null);
242
271
  // Sort order, primary first. Empty = unsorted. Multiple keys via Shift-click.
243
272
  // Controlled by the `sort` prop when provided, else internal state.
244
273
  let internalSorts = $state<SortState[]>([]);
@@ -512,6 +541,12 @@
512
541
  const COL_OVERSCAN = 320; // px
513
542
  let scrollLeft = $state(0);
514
543
  let viewW = $state(0);
544
+ // Viewport height in px for row virtualization. A numeric `height` is that
545
+ // value directly; a CSS-string `height` (auto-fit) is the measured viewport
546
+ // height (clientHeight), with a sane fallback before the first measure.
547
+ const heightIsPx = $derived(typeof height === 'number');
548
+ let viewH = $state(0);
549
+ const viewPx = $derived(heightIsPx ? (height as number) : viewH || 400);
515
550
  type ColItem = { kind: 'cell'; ci: number; key: string } | { kind: 'spacer'; w: number; key: string };
516
551
  const colItems = $derived.by<ColItem[]>(() => {
517
552
  const n = cols.length;
@@ -902,15 +937,24 @@
902
937
  });
903
938
 
904
939
  // Pagination (in-memory only): slice the view into pages; rows still
905
- // virtualize within a page. Off when pageSize <= 0.
906
- const paged = $derived(pageSize > 0 && !source);
940
+ // virtualize within a page. Off when the page size is <= 0.
941
+ // Effective page size: a runtime pick from `pageSizeOptions` (uncontrolled),
942
+ // else the `pageSize` prop.
943
+ let internalPageSize = $state<number | null>(null);
944
+ const effPageSize = $derived(internalPageSize ?? pageSize);
945
+ function setPageSize(size: number): void {
946
+ internalPageSize = size;
947
+ onPageSizeChange?.(size);
948
+ setPage(0); // a new page size invalidates the current page index
949
+ }
950
+ const paged = $derived(effPageSize > 0 && !source);
907
951
  let internalPage = $state(0);
908
952
  const currentPage = $derived(page ?? internalPage); // controlled by `page` prop, else internal
909
953
  function setPage(p: number): void {
910
954
  if (page === undefined) internalPage = p; // uncontrolled: own the state
911
955
  onPageChange?.(p); // always notify
912
956
  }
913
- const pageCount = $derived(paged ? Math.max(1, Math.ceil(view.length / pageSize)) : 1);
957
+ const pageCount = $derived(paged ? Math.max(1, Math.ceil(view.length / effPageSize)) : 1);
914
958
  // Keep the page in range when the view shrinks (filter/sort changes).
915
959
  $effect(() => {
916
960
  const max = pageCount - 1;
@@ -919,7 +963,7 @@
919
963
  });
920
964
  });
921
965
  const pageRows = $derived(
922
- paged ? view.slice(currentPage * pageSize, currentPage * pageSize + pageSize) : view,
966
+ paged ? view.slice(currentPage * effPageSize, currentPage * effPageSize + effPageSize) : view,
923
967
  );
924
968
 
925
969
  const treeData = $derived(!!(getChildren || loadChildren) && !source);
@@ -929,7 +973,7 @@
929
973
  // Drag-to-reorder rows (flat, unsorted, in-memory only). The handle lives in
930
974
  // the first cell; the dragged/drop indices are tracked in component state.
931
975
  const reorderable = $derived(
932
- !!onRowReorder && !source && !treeData && groupBy.length === 0 && pageSize <= 0,
976
+ !!onRowReorder && !source && !treeData && groupBy.length === 0 && effPageSize <= 0,
933
977
  );
934
978
  let dragRowVr = $state(-1);
935
979
  let dropRowVr = $state(-1);
@@ -1059,12 +1103,12 @@
1059
1103
  const rowWidthStyle = $derived(
1060
1104
  hScroll ? `width:${layout.totalWidth + leadPx}px;right:auto;` : '',
1061
1105
  );
1062
- const visibleCount = $derived(Math.ceil(height / baseH) + OVERSCAN * 2);
1106
+ const visibleCount = $derived(Math.ceil(viewPx / baseH) + OVERSCAN * 2);
1063
1107
  const start = $derived(Math.max(0, hm.indexAt(scrollTop) - OVERSCAN));
1064
1108
  const renderEnd = $derived(
1065
1109
  source
1066
1110
  ? (controller && controller.total > 0 ? Math.min(start + visibleCount, controller.total) : start + visibleCount)
1067
- : Math.min(flat.length, hm.indexAt(scrollTop + height) + OVERSCAN + 1),
1111
+ : Math.min(flat.length, hm.indexAt(scrollTop + viewPx) + OVERSCAN + 1),
1068
1112
  );
1069
1113
 
1070
1114
  type RenderItem =
@@ -1139,6 +1183,27 @@
1139
1183
  if (hScroll && headEl) headEl.scrollLeft = el.scrollLeft; // keep header in sync
1140
1184
  if (hScroll && filterRowEl) filterRowEl.scrollLeft = el.scrollLeft; // and the filter row
1141
1185
  if (hScroll && groupHeadEl) groupHeadEl.scrollLeft = el.scrollLeft; // and group headers
1186
+ if (tip) tip = null; // a stale fixed-position bubble would float away on scroll
1187
+ }
1188
+
1189
+ // Tooltip hover delegation (only wired when a column opts in). Matches both
1190
+ // data cells (`.c[data-bo-tip]`) and column headers (`.h[data-bo-tip]`).
1191
+ function onTipOver(e: PointerEvent) {
1192
+ const cell = (e.target as HTMLElement | null)?.closest?.('[data-bo-tip]') as HTMLElement | null;
1193
+ const text = cell?.dataset.boTip;
1194
+ if (!cell || !text) {
1195
+ if (tip) tip = null;
1196
+ return;
1197
+ }
1198
+ const rect = cell.getBoundingClientRect();
1199
+ const below = rect.top < 72; // not enough room above near the header — flip down
1200
+ const x = Math.min(Math.max(rect.left + rect.width / 2, 96), window.innerWidth - 96);
1201
+ tip = { text, x, y: below ? rect.bottom + 7 : rect.top - 7, below };
1202
+ }
1203
+ function onTipOut(e: PointerEvent) {
1204
+ const to = e.relatedTarget as HTMLElement | null;
1205
+ if (to?.closest?.('[data-bo-tip]')) return; // moved within the same tipped cell/header
1206
+ if (tip) tip = null;
1142
1207
  }
1143
1208
 
1144
1209
  function toggleGroup(path: string) {
@@ -1150,6 +1215,8 @@
1150
1215
 
1151
1216
  function onCellDown(r: number, c: number, e: PointerEvent) {
1152
1217
  if (e.button !== 0) return;
1218
+ // Display grid: skip range selection but let onclick (row/cell click) fire.
1219
+ if (!cellSelection) return;
1153
1220
  e.preventDefault();
1154
1221
  gridEl?.focus();
1155
1222
  if (e.shiftKey) sel.extendTo(r, c);
@@ -1354,7 +1421,7 @@
1354
1421
  const top = hm.offsetOf(f.r);
1355
1422
  const h = hm.heightOf(f.r);
1356
1423
  if (top < viewportEl.scrollTop) viewportEl.scrollTop = top;
1357
- else if (top + h > viewportEl.scrollTop + height) viewportEl.scrollTop = top + h - height;
1424
+ else if (top + h > viewportEl.scrollTop + viewPx) viewportEl.scrollTop = top + h - viewPx;
1358
1425
  }
1359
1426
 
1360
1427
  async function copySelection() {
@@ -1540,7 +1607,7 @@
1540
1607
  const f = sel.focus;
1541
1608
  if (f && (e.key === 'Home' || e.key === 'End' || e.key === 'PageUp' || e.key === 'PageDown')) {
1542
1609
  e.preventDefault();
1543
- const page = Math.max(1, Math.floor(height / baseH) - 1);
1610
+ const page = Math.max(1, Math.floor(viewPx / baseH) - 1);
1544
1611
  if (e.key === 'Home') focusTo(mod ? 0 : f.r, 0, e.shiftKey);
1545
1612
  else if (e.key === 'End') focusTo(mod ? maxR : f.r, maxC, e.shiftKey);
1546
1613
  else if (e.key === 'PageDown') focusTo(f.r + page, f.c, e.shiftKey);
@@ -1594,7 +1661,7 @@
1594
1661
  aria-colcount={cols.length + leadCols}
1595
1662
  aria-multiselectable="true"
1596
1663
  aria-activedescendant={activeId}
1597
- style={themeStyle}
1664
+ style="{themeStyle}{heightIsPx ? '' : `;height:${height};min-height:0`}"
1598
1665
  bind:this={gridEl}
1599
1666
  onkeydown={onKeydown}
1600
1667
  >
@@ -1623,7 +1690,17 @@
1623
1690
  {/each}
1624
1691
  </div>
1625
1692
  {/if}
1626
- <div class="head" role="row" aria-rowindex={1} bind:this={headEl} style={hScroll ? 'overflow:hidden;' : ''}>
1693
+ <!-- Tooltip hover delegation; header semantics live on the columnheader buttons. -->
1694
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
1695
+ <div
1696
+ class="head"
1697
+ role="row"
1698
+ aria-rowindex={1}
1699
+ bind:this={headEl}
1700
+ style={hScroll ? 'overflow:hidden;' : ''}
1701
+ onpointerover={hasTooltips ? onTipOver : undefined}
1702
+ onpointerout={hasTooltips ? onTipOut : undefined}
1703
+ >
1627
1704
  {#if expandable}
1628
1705
  <span class="expandcell selhead" role="columnheader" aria-colindex={1} style={expandCellStyle(true)}></span>
1629
1706
  {/if}
@@ -1652,6 +1729,7 @@
1652
1729
  type="button"
1653
1730
  role="columnheader"
1654
1731
  aria-colindex={ci + 1 + leadCols}
1732
+ data-bo-tip={col.headerTooltip}
1655
1733
  draggable="true"
1656
1734
  aria-sort={isSortable(col) && sortInfo(col.key)
1657
1735
  ? sortInfo(col.key)?.dir === 'asc'
@@ -1682,6 +1760,9 @@
1682
1760
  }}
1683
1761
  >
1684
1762
  <span class="label">{col.header}</span>
1763
+ {#if col.headerTooltip && col.headerInfo}
1764
+ <span class="hinfo" aria-hidden="true">i</span>
1765
+ {/if}
1685
1766
  {#if isSortable(col) && sortInfo(col.key)}
1686
1767
  {@const si = sortInfo(col.key)}
1687
1768
  <span class="ind">
@@ -1762,12 +1843,18 @@
1762
1843
  </div>
1763
1844
  {/if}
1764
1845
 
1846
+ <!-- Tooltip hover delegation lives on the scroll container; the grid's
1847
+ interactive semantics are on the role="grid" root. -->
1848
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
1765
1849
  <div
1766
1850
  class="viewport"
1767
- style="height:{height}px;{hScroll ? 'overflow-x:auto;' : ''}"
1851
+ style="{heightIsPx ? `height:${height}px` : 'flex:1 1 auto;min-height:0'};{hScroll ? 'overflow-x:auto;' : ''}"
1768
1852
  bind:this={viewportEl}
1769
1853
  bind:clientWidth={viewW}
1854
+ bind:clientHeight={viewH}
1770
1855
  onscroll={onScroll}
1856
+ onpointerover={hasTooltips ? onTipOver : undefined}
1857
+ onpointerout={hasTooltips ? onTipOut : undefined}
1771
1858
  >
1772
1859
  {#if pinnedRows.length > 0}
1773
1860
  <div class="pinned-top">
@@ -1839,7 +1926,7 @@
1839
1926
  {:else}
1840
1927
  <!-- Row activation is keyboard-accessible at the grid level: Enter on the focused cell fires onRowClick (focus is via aria-activedescendant). -->
1841
1928
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
1842
- <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}>
1929
+ <div class="row {rowClass?.(item.row) ?? ''}" class:alt={item.vr % 2 === 1} class:rowsel={rowSelection && isRowSelected(getRowId(item.row))} class:rowactive={selectedRowId != null && getRowId(item.row) === selectedRowId} 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}>
1843
1930
  {#if expandable}
1844
1931
  <span class="expandcell" style={expandCellStyle(false)}>
1845
1932
  <button
@@ -1882,8 +1969,8 @@
1882
1969
  colIndex={ci + 1 + leadCols}
1883
1970
  cellId={`${gid}-r${item.vr}-c${ci}`}
1884
1971
  cellSnippet={cell}
1885
- selected={sel.contains(item.vr, ci)}
1886
- focused={sel.isFocus(item.vr, ci)}
1972
+ selected={cellSelection && sel.contains(item.vr, ci)}
1973
+ focused={cellSelection && sel.isFocus(item.vr, ci)}
1887
1974
  pinned={pinned && layout.info[ci].pinned}
1888
1975
  pinSide={layout.info[ci].side ?? 'left'}
1889
1976
  pinOffset={layout.info[ci].side === 'right' ? layout.info[ci].right : layout.info[ci].left + leadPx}
@@ -1945,7 +2032,15 @@
1945
2032
  <AggregationBar result={agg} kinds={aggregations} />
1946
2033
 
1947
2034
  {#if paged}
1948
- <Pager page={currentPage} {pageCount} total={view.length} onGoto={goToPage} />
2035
+ <Pager
2036
+ page={currentPage}
2037
+ {pageCount}
2038
+ total={view.length}
2039
+ onGoto={goToPage}
2040
+ pageSize={effPageSize}
2041
+ {pageSizeOptions}
2042
+ onPageSize={setPageSize}
2043
+ />
1949
2044
  {/if}
1950
2045
 
1951
2046
  {#if menu}
@@ -1979,6 +2074,18 @@
1979
2074
  onClose={() => (panelXY = null)}
1980
2075
  />
1981
2076
  {/if}
2077
+
2078
+ {#if tip}
2079
+ <div
2080
+ class="bo-tip"
2081
+ class:below={tip.below}
2082
+ role="tooltip"
2083
+ aria-hidden="true"
2084
+ style="left:{tip.x}px;top:{tip.y}px"
2085
+ >
2086
+ {tip.text}
2087
+ </div>
2088
+ {/if}
1982
2089
  </div>
1983
2090
 
1984
2091
  <style>
@@ -2022,6 +2129,65 @@
2022
2129
  .grid:focus-visible {
2023
2130
  border-color: var(--bo-sel-border);
2024
2131
  }
2132
+ /* Styled floating tooltip (opt-in via column `tooltip`). Fixed-positioned so
2133
+ it escapes the cell/viewport overflow; centred on the cell, flips below
2134
+ near the header. */
2135
+ .bo-tip {
2136
+ position: fixed;
2137
+ z-index: 60;
2138
+ max-width: 280px;
2139
+ padding: 5px 9px;
2140
+ font-family: var(--bo-sans);
2141
+ font-size: 12px;
2142
+ line-height: 1.45;
2143
+ color: var(--bo-text);
2144
+ background: var(--bo-header-bg);
2145
+ border: 0.5px solid var(--bo-border);
2146
+ border-radius: 6px;
2147
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
2148
+ white-space: normal;
2149
+ overflow-wrap: anywhere;
2150
+ pointer-events: none;
2151
+ transform: translate(-50%, -100%);
2152
+ animation: bo-tip-in 90ms ease-out;
2153
+ }
2154
+ .bo-tip.below {
2155
+ transform: translate(-50%, 0);
2156
+ }
2157
+ /* Caret. */
2158
+ .bo-tip::after {
2159
+ content: "";
2160
+ position: absolute;
2161
+ left: 50%;
2162
+ width: 7px;
2163
+ height: 7px;
2164
+ background: var(--bo-header-bg);
2165
+ border: 0.5px solid var(--bo-border);
2166
+ transform: translateX(-50%) rotate(45deg);
2167
+ }
2168
+ .bo-tip:not(.below)::after {
2169
+ bottom: -4px;
2170
+ border-top: 0;
2171
+ border-left: 0;
2172
+ }
2173
+ .bo-tip.below::after {
2174
+ top: -4px;
2175
+ border-bottom: 0;
2176
+ border-right: 0;
2177
+ }
2178
+ @keyframes bo-tip-in {
2179
+ from {
2180
+ opacity: 0;
2181
+ }
2182
+ to {
2183
+ opacity: 1;
2184
+ }
2185
+ }
2186
+ @media (prefers-reduced-motion: reduce) {
2187
+ .bo-tip {
2188
+ animation: none;
2189
+ }
2190
+ }
2025
2191
  /* Built-in quick-filter toolbar (opt-in via `quickFilter`). */
2026
2192
  .bo-toolbar {
2027
2193
  display: flex;
@@ -2174,6 +2340,28 @@
2174
2340
  font-size: 9px;
2175
2341
  color: var(--bo-text);
2176
2342
  }
2343
+ /* Header info cue: a small circled "i" signalling a headerTooltip. */
2344
+ .h .hinfo {
2345
+ display: inline-flex;
2346
+ align-items: center;
2347
+ justify-content: center;
2348
+ flex: none;
2349
+ width: 12px;
2350
+ height: 12px;
2351
+ margin-left: 1px;
2352
+ font-size: 8px;
2353
+ font-style: italic;
2354
+ font-family: var(--bo-mono);
2355
+ line-height: 1;
2356
+ color: var(--bo-text-dim);
2357
+ border: 0.5px solid var(--bo-text-dim);
2358
+ border-radius: 50%;
2359
+ pointer-events: none;
2360
+ }
2361
+ .h:hover .hinfo {
2362
+ color: var(--bo-text);
2363
+ border-color: var(--bo-text);
2364
+ }
2177
2365
  .h .ind .ord {
2178
2366
  font-size: 8px;
2179
2367
  line-height: 1;
@@ -2374,6 +2562,12 @@
2374
2562
  .row.rowsel {
2375
2563
  background: var(--bo-sel-fill);
2376
2564
  }
2565
+ /* Controlled active-row highlight (selectedRowId) — a tint + an accent bar so
2566
+ it reads distinctly from the checkbox selection and hover. */
2567
+ .row.rowactive {
2568
+ background: var(--bo-sel-fill);
2569
+ box-shadow: inset 2px 0 0 var(--bo-sel-border);
2570
+ }
2377
2571
  .row.clickable {
2378
2572
  cursor: pointer;
2379
2573
  }
@@ -8,7 +8,12 @@ import type { RowSource } from './source';
8
8
  type $$ComponentProps = {
9
9
  rows: GridRow[];
10
10
  columns: ColumnDef[];
11
- height: number;
11
+ /** Viewport height. A **number** is the scroll viewport's pixel height (the
12
+ grid's total height is that plus header/toolbar/footer chrome). A **CSS
13
+ string** (e.g. `'100%'`, `'80vh'`, `'480px'`) sizes the whole grid element
14
+ and the viewport auto-fits the space left after the chrome — give the grid
15
+ a sized parent (or use `'100%'` inside a flex/grid cell). */
16
+ height: number | string;
12
17
  /** Row height in px (uniform), or a function for variable heights
13
18
  (in-memory mode only). Default 36. */
14
19
  rowHeight?: number | ((row: GridRow, index: number) => number);
@@ -17,6 +22,11 @@ type $$ComponentProps = {
17
22
  /** Allow drag-to-resize column widths. Default true; opt out per column
18
23
  with `resizable: false`. */
19
24
  resizable?: boolean;
25
+ /** Highlight cells/ranges on click-drag (the blue selection fill + focus
26
+ ring + fill handle). Set false for a read-only/display grid where clicks
27
+ should pass straight through to `onRowClick`/`onCellClick` without a
28
+ selection highlight. Default true. */
29
+ cellSelection?: boolean;
20
30
  /** Show a leading checkbox column for whole-row selection (keyed by row id,
21
31
  stable across sort/filter). Default false. */
22
32
  rowSelection?: boolean;
@@ -46,6 +56,10 @@ type $$ComponentProps = {
46
56
  /** Identity key for row selection. Defaults to `row.id`; override for
47
57
  string/UUID/composite keys. */
48
58
  getRowId?: (row: GridRow) => string | number;
59
+ /** Controlled "active row" highlight (keyed by `getRowId`) — for
60
+ master-detail / list-detail where one row is selected. Independent of the
61
+ checkbox `rowSelection` and the cell range selection. `null` = none. */
62
+ selectedRowId?: string | number | null;
49
63
  /** Called when a data row is activated by click or Enter (open a detail
50
64
  view, navigate, …). Edit-input and checkbox clicks are excluded. */
51
65
  onRowClick?: (row: GridRow, event: MouseEvent | KeyboardEvent) => void;
@@ -134,6 +148,12 @@ type $$ComponentProps = {
134
148
  /** Rows per page. When > 0 (in-memory mode), shows a pager instead of one
135
149
  long scroll; rows still virtualize within a page. Default 0 (off). */
136
150
  pageSize?: number;
151
+ /** Page-size choices for a dropdown in the pager (e.g. `[25, 50, 100]`).
152
+ Picking one re-pages from page 0. Uncontrolled unless you also drive
153
+ `pageSize` and update it from `onPageSizeChange`. */
154
+ pageSizeOptions?: number[];
155
+ /** Called with the new page size when the pager's size dropdown changes. */
156
+ onPageSizeChange?: (size: number) => void;
137
157
  /** Controlled current page (0-based). Omit for uncontrolled paging. */
138
158
  page?: number;
139
159
  /** Called with the new page index when the pager is used. */