# Props reference

> Every Grid prop, column option and grid API method, generated from the TypeScript types (bo-grid 3.1.0). As JSON: https://bonguynvan.github.io/bo-grid/api.json

## Grid props

- `rows`: `GridRow[]` — The data: one plain object per row, each with a unique `id`, a number or a string (or name another key with `getRowId`).

  ```ts
  let rows: GridRow[] = $state([
    { id: 1, sym: 'VNM', last: 68.5 },
    { id: 2, sym: 'FPT', last: 121.3 },
  ]);
  ```

- `columns`: `ColumnDef[]` — The columns, in display order. A column's `type` picks how its values show, sort and filter.

  ```ts
  const columns: ColumnDef[] = [
    { type: 'text', key: 'sym', header: 'Symbol', width: 90 },
    { type: 'price', key: 'last', header: 'Last', flash: 'auto' },
  ];
  ```

- `height`: `number | string` — Viewport height. A **number** is the scroll viewport's pixel height (the grid's total height is that plus header/toolbar/footer chrome). A **CSS string** (e.g. `'100%'`, `'80vh'`, `'480px'`) sizes the whole grid element and the viewport auto-fits the space left after the chrome — give the grid a sized parent (or use `'100%'` inside a flex/grid cell).

  ```svelte
  <Grid {rows} {columns} height="100%" />
  ```

- `rowHeight`: `number | ((row: GridRow, index: number) => number)` (default `36`) — Row height in px (uniform), or a function for variable heights (in-memory mode only).

  ```svelte
  <Grid {rows} {columns} height={480} rowHeight={(row) => (row.note ? 56 : 36)} />
  ```

- `theme`: `'dark' | 'light' | GridTheme` (default `'dark'`) — Built-in theme name, or a token map (a preset such as `midnightTheme`, or your own, spread over one).

  ```svelte
  <Grid {rows} {columns} height={480} theme={{ ...lightTheme, up: '#0a7f4f' }} />
  ```

- `resizable`: `boolean` (default `true`) — Allow drag-to-resize column widths. A drag (or an autosize) changes that column only: the others keep the width they have on screen, flex columns included, so the ones after it move along. Double-click a grip to give that column back its own width; Reset widths in the column menu gives every column back its own. Opt out per column with `resizable: false`.
- `cellSelection`: `boolean` (default `true`) — Highlight cells/ranges on click-drag (the blue selection fill + focus ring + fill handle). Set false for a read-only/display grid where clicks should pass straight through to `onRowClick`/`onCellClick` without a selection highlight.
- `rowSelection`: `boolean` (default `false`) — Show a leading checkbox column for whole-row selection (keyed by row id, stable across sort/filter).
- `onRowSelectionChange`: `(selectedIds: Array<string | number>) => void` — Called with the selected row ids whenever the row-selection set changes.

  ```svelte
  <Grid {rows} {columns} height={480} rowSelection onRowSelectionChange={(ids) => (selected = ids)} />
  ```

- `hiddenColumns`: `string[]` (default `[]`) — Column keys to hide (controlled). Build your own column-picker UI and drive this prop — the grid stays presentation-only. Composed (union) with columns the user hides at runtime via the column menu.

  ```svelte
  <Grid {rows} {columns} height={480} hiddenColumns={showVolume ? [] : ['vol']} />
  ```

- `onColumnVisibilityChange`: `(hidden: string[]) => void` — Called with all currently-hidden column keys whenever the runtime set changes (column menu hide/show).

  ```svelte
  <Grid {rows} {columns} height={480} columnMenu onColumnVisibilityChange={(hidden) => save('hidden', hidden)} />
  ```

- `columnMenu`: `boolean` (default `false`) — Enable a per-column header menu (a ⋮ trigger) with sort, hide and (with `filterMenu`) filter actions.
- `columnsPanel`: `boolean` (default `false`) — Show a "Columns" button that opens a panel to toggle column visibility (the place to restore columns hidden via the menu). Lazy-loaded.
- `virtualizeColumns`: `boolean` (default `false`) — Render only the columns within the horizontal scroll window (+ overscan) for very wide grids (100+ columns). Forces fixed-width horizontal scroll; pinned columns always render. Off, the columns fit the grid's width.
- `rowClass`: `(row: GridRow) => string | undefined` — Return extra CSS class(es) for a data row (e.g. to colour by value). Style them via `:global(.your-class)` since rows live inside the grid.

  ```svelte
  <Grid {rows} {columns} height={480} rowClass={(row) => (row.status === 'Rejected' ? 'rejected' : undefined)} />
  ```

- `getRowId`: `(row: GridRow) => string | number` (default `(row) => row.id`) — Identity key for rows: selection, focus, pins and flashes follow it. Override for string/UUID/composite keys.

  ```svelte
  <Grid {rows} {columns} height={480} getRowId={(row) => `${row.account}:${row.sym}`} />
  ```

- `selectedRowId`: `string | number | null` (default `null`) — Controlled "active row" highlight (keyed by `getRowId`) — for master-detail / list-detail where one row is selected. Independent of the checkbox `rowSelection` and the cell range selection. `null` = none.
- `onRowClick`: `(row: GridRow, event: MouseEvent | KeyboardEvent) => void` — Called when a data row is activated by click or Enter (open a detail view, navigate, …). Edit-input and checkbox clicks are excluded.

  ```svelte
  <Grid {rows} {columns} height={480} {selectedRowId} onRowClick={(row) => (selectedRowId = row.id)} />
  ```

- `hotkeys`: `Hotkeys` (default `undefined`) — Keys for the app, run before the grid's own. A binding is a key (`'b'`, `'Delete'`, `'Space'`; a symbol by the character it types: `'?'`, not `'Shift+/'`), after any of `Shift+`, `Alt+`, `Ctrl+`, `Meta+` and `Mod+` (Ctrl or ⌘, either). Case does not matter. The handler is given the focused row and column (null when there is none) and the event; return false to leave the key to the grid. Keys go to the grid, not to a binding, while a cell is edited or an input, button or menu inside the grid has them, and a key a binding took runs it once when held down. A binding that cannot be read, or binds a key twice, throws (BG1001–BG1004).

  ```svelte
  <Grid {rows} {columns} height={480}
    hotkeys={{ b: ({ row }) => { if (row) buy(row); }, 'Shift+C': cancelAll, Delete: ({ row }) => { if (row) cancel(row); } }} />
  ```

- `sort`: `SortState[]` (default `undefined`) — Controlled sort order (multi-key, primary first). When set, the grid reflects this and reports changes via `onSortChange` instead of holding its own state. Omit for uncontrolled sorting.

  ```svelte
  <Grid {rows} {columns} height={480} {sort} onSortChange={(next) => (sort = next)} />
  ```

- `onSortChange`: `(sort: SortState[]) => void` — Called with the new sort order whenever a header is clicked.

  ```svelte
  <Grid {rows} {columns} height={480} onSortChange={(sort) => save('sort', sort)} />
  ```

- `columnFilters`: `Record<string, AnyFilter>` (default `undefined`) — Controlled column filters (keyed by column key). When set, the grid reflects these and reports changes via `onFilterChange` instead of holding its own. Omit for uncontrolled filtering.

  ```svelte
  <Grid {rows} {columns} height={480} filterMenu
    columnFilters={{ sym: { kind: 'text', op: 'starts', q: 'V' }, last: { kind: 'number', op: 'gt', a: 50 } }} />
  ```

- `onFilterChange`: `(filters: Record<string, AnyFilter>) => void` — Called with the full column-filter map whenever a header filter changes.

  ```svelte
  <Grid {rows} {columns} height={480} filterMenu {columnFilters} onFilterChange={(next) => (columnFilters = next)} />
  ```

- `footer`: `boolean` (default `false`) — Show a pinned totals row: each column with a `groupAgg` shows that aggregate over all (filtered) rows. In-memory mode only.
- `onCellClick`: `( info: { row: GridRow; column: ColumnDef; value: unknown }, event: MouseEvent, ) => void` — Called when a cell is clicked, with its row, column and value. Fires in addition to `onRowClick`; excluded for the edit input.

  ```svelte
  <Grid {rows} {columns} height={480} onCellClick={({ row, column }) => column.key === 'sym' && openChart(row.sym)} />
  ```

- `pinnedRows`: `GridRow[]` (default `[]`) — Rows pinned to the top, always visible above the scroll (a benchmark, a summary, "your position"). Display-only — not virtualized or selectable. Their `getRowId` must not collide with a row in `rows`/`source` — with a derived-flash column (`flash: 'auto'`), a shared id means the pinned cell and the data-row cell share one flash-tracker entry, so updating one can suppress or trigger a flash on the other. To let users pin rows of `rows` themselves, use `rowPinning`.

  ```svelte
  <Grid {rows} {columns} height={480} pinnedRows={[{ id: -1, sym: 'VN30', last: index }]} />
  ```

- `filterRow`: `boolean` (default `false`) — Show a per-column filter input row under the header. Rows must match every non-empty column filter (AND). In-memory mode only.
- `filterMenu`: `boolean` (default `false`) — Enable a per-column header filter menu (lazy-loaded on first open). Each filterable column shows a funnel; the menu's control matches the column type (text/number/date). Override or disable per column with `col.filter`. Works in source mode too (filters are delegated to the `RowSource`); set filters need in-memory data.
- `quickFilter`: `boolean` (default `false`) — Show a built-in quick-filter search box above the grid that matches across all column values (ANDed with the `filter` prop). With a `source`, its text is sent to `getRows` as `quickFilter`.
- `fillHandle`: `boolean` (default `false`) — Show an Excel-style fill handle at the selection's bottom-right corner; drag it to copy the selected value(s) across the extended range (editable columns only). In-memory mode only.
- `emptyMessage`: `string` (default `labels.noRows`) — Message shown when there are no rows.
- `labels`: `Partial<GridLabels>` (default `{}`) — Override any string the grid renders itself (menus, filter editor, pager, aria labels). Merged over English defaults — pass only what changes. Interpolated entries are functions so translations can reorder the parts. Resolved per grid.

  ```svelte
  <Grid {rows} {columns} height={480} labels={{ noRows: 'Không có dữ liệu', sortAscending: 'Tăng dần' }} />
  ```

- `defaultColumn`: `DefaultColumn` (default `undefined`) — Settings applied under every column — its own fields (and a registered `cellType`) win.

  ```svelte
  <Grid {rows} {columns} height={480} defaultColumn={{ minWidth: 72, sortable: false }} />
  ```

- `cellTypes`: `Record<string, CellTypeDef>` (default `undefined`) — Reusable column types, by name. A column with `cellType: 'name'` gets that entry's defaults (format, compare, align, filter, width, a `component` renderer…) under its own fields, and the built-in type named by `extends` for sorting, filtering and export.

  ```svelte
  <Grid {rows} {columns} height={480}
    cellTypes={{ qty: { extends: 'number', decimals: 0, width: 80, format: (v) => Number(v).toLocaleString() } }} />
  ```

- `filterTypes`: `FilterTypes` (default `undefined`) — Custom filter kinds, by name. A column selects one with `filter: 'name'`; the header filter menu draws its `component` and the view keeps rows for which `test` passes. An unregistered kind filters nothing.

  ```svelte
  <Grid {rows} {columns} height={480} filterMenu
    filterTypes={{ band: { component: BandFilter, test: (v, f) => Number(v) >= f.min && Number(v) <= f.max } }} />
  ```

- `locale`: `string` (default `undefined`) — BCP 47 locale for the grid's own number formatting (aggregation bar, pager row count), and the default for each column's `locale`. Unset, the runtime's locale.
- `onReady`: `(api: GridApi) => void` — Called once, when the grid mounts, with a handle for imperative actions: `scrollToRow`, `focusCell`, `patchRows`, `getSelectedRows`, `autosizeColumns`, `exportCSV`, `getState` / `applyState` and more.

  ```svelte
  <Grid {rows} {columns} height={480} onReady={(a) => (api = a)} />
  ```

- `onViewportChange`: `(view: ViewportRange) => void` — Called with the data rows on screen — the rows in view plus the small buffer rendered around them — whenever that set changes (scroll, sort, filter, new data), never for a tick that only changes values. Pass it `createViewportSubscriptions(...).update` from `bo-grid/realtime` to stream just the symbols in view.

  ```svelte
  <Grid {rows} {columns} height={480} onViewportChange={subs.update} />
  ```

- `flashMotion`: `'fade' | 'hold' | 'auto'` (default `'fade'`) — How a flash plays. `'fade'`: the tint fades out. `'hold'`: the tint holds for the flash window, then drops — no CSS animation, so a busy board restyles and repaints a flashing cell twice instead of every frame. `'auto'`: fade while few cells flash at once, hold when many do. A flash that lights whole rows (`flashNewRows`, `api.flashCells`) counts each cell it lights.
- `liveSort`: `boolean | number` (default `false`) — Keep the sort and filters current while `api.patchRows` changes values in place. When a patch changes a field the grid sorts or filters by, the rows it changed are merged back into place at most every `liveSort` ms (`true`: 500), instead of the whole view being re-sorted. The selection stays on the same rows; nothing moves while a cell is being edited. Off, rows hold their place until `api.refresh()`. It follows `patchRows`; values written into rows any other way still need `api.refresh()`.

  ```svelte
  <Grid {rows} {columns} height={480} liveSort={250} />
  ```

- `pauseOnHover`: `boolean` (default `true`) — While the pointer is over the rows, `liveSort` waits, so a row does not move just as it is clicked; the rows re-sort once the pointer leaves (or the window loses focus, or the tab is hidden). Mouse and pen only. Set false for a screen nobody points at.
- `staleAfter`: `number` (default `0`) — Dim a row when nothing new has come for it for this many ms: a quote whose feed went quiet. News is a patch (`api.patchRows`) that changed the row, or a new object for it in `rows` (the row arrived, or a copy replaced it); a patch that re-sends the values the row already has is not. The row comes back the moment it ticks. Rows already there count from mount, or from when `staleAfter` is turned on. Dimmed with `opacity: var(--bo-stale-opacity, 0.5)`. `0` turns it off.
- `flashNewRows`: `boolean | number` (default `false`) — Flash a row once when it arrives in `rows` (a new order, a new fill): every cell, in the neutral flash colour, for this many ms (`true`: 600). A row arrives when its id is new; a copy of a row already there does not flash, nor do the rows an empty grid gets (a load or a reload), nor a wholesale replacement (more than 64 rows, and more than an eighth of them, at once). Rows on screen only, as with every flash.

  ```svelte
  <Grid {rows} {columns} height={480} flashNewRows={900} />
  ```

- `columnHover`: `boolean` (default `false`) — Highlight the column under the pointer (body and header) — handy on a wide board. One overlay for the whole column, not a class per cell.
- `rowNumbers`: `boolean` (default `false`) — A leading row-number column (1, 2, 3… in view order; data rows only), as in a spreadsheet. Its cells are row headers, so screen readers announce the number with each row.
- `rowPinning`: `boolean` (default `false`) — Let users pin rows above the scroll from the row menu (right-click): "Pin to top" / "Unpin". A pinned row stays in the body too, keeps updating, and is remembered with `persistKey` and in `getState()`.
- `findBar`: `boolean` (default `false`) — Ctrl/⌘+F in the grid opens a find bar: it matches the cells' displayed text (case-insensitive) and steps through matches with Enter / Shift+Enter, focusing and scrolling to each. Escape closes it. Off by default, as it takes Ctrl+F from the browser while the grid has focus. In-memory rows only.
- `fitColumns`: `boolean` (default `false`) — Scale every column with the grid, in proportion to its `width`, instead of only `flex` columns taking up the change: the grid stays responsive without hand-tuned flex weights. A column never gets narrower than its `minWidth` (64 px, or its own width if smaller, without one) nor wider than its `maxWidth`; when the floors no longer fit, the grid scrolls sideways. It fits until the user sets a width (a drag, an autosize): from then on the columns keep the widths they were given, until Reset widths in the column menu.
- `onRowPinChange`: `(ids: (string | number)[]) => void` — Called with the pinned row ids (in pin order) whenever they change.

  ```svelte
  <Grid {rows} {columns} height={480} rowPinning onRowPinChange={(ids) => save('pinned', ids)} />
  ```

- `loading`: `boolean` (default `false`) — Show a loading overlay over the grid (for consumer-driven async work in in-memory mode; source mode shows skeleton rows automatically).
- `rowMenu`: `(row: GridRow) => Array<{ label: string; onSelect: () => void }>` — Right-click row menu. Return the items for a row; an empty array shows no menu. Each item runs `onSelect` and closes the menu.

  ```svelte
  <Grid {rows} {columns} height={480}
    rowMenu={(row) => [{ label: 'Cancel order', onSelect: () => cancel(row) }, { label: 'Copy id', onSelect: () => copy(String(row.id)) }]} />
  ```

- `detail`: `Snippet<[{ row: GridRow }]>` — Master-detail: render an expandable detail panel under a row. Adds a leading expand-toggle column. In-memory mode only (overrides rowHeight).

  ```svelte
  <Grid {rows} {columns} height={480}>
    {#snippet detail({ row })}<p>{row.sym}: {row.note}</p>{/snippet}
  </Grid>
  ```

- `detailHeight`: `number` (default `160`) — Height (px) of the expanded detail panel.
- `getChildren`: `(row: GridRow) => GridRow[] | undefined` — Tree data: return a row's children (undefined/empty = leaf). When set, `rows` are the roots; the grid renders an indented, expandable tree. In-memory mode; filter/sort/group/paginate are not applied to the tree.

  ```svelte
  <Grid rows={roots} {columns} height={480} getChildren={(row) => row.children as GridRow[] | undefined} />
  ```

- `loadChildren`: `(row: GridRow) => Promise<GridRow[]>` — Async tree data: load a row's children on expand (server-backed trees). Returns a promise; the grid shows a loading row, then caches the result. Pair with `hasChildren` (a cheap predicate) so chevrons show without loading. Use instead of `getChildren`. In-memory roots.

  ```svelte
  <Grid rows={roots} {columns} height={480}
    loadChildren={(row) => fetch(`/api/accounts/${row.id}/children`).then((r) => r.json())}
    hasChildren={(row) => Number(row.childCount) > 0} />
  ```

- `hasChildren`: `(row: GridRow) => boolean` — Cheap predicate for whether a row has children (drives the expand chevron without loading). Required with `loadChildren`; optional with `getChildren`.

  ```svelte
  <Grid rows={roots} {columns} height={480} {loadChildren} hasChildren={(row) => row.kind === 'folder'} />
  ```

- `lazyGroups`: `LazyGroup[]` (default `undefined`) — Server-side grouping: top-level group summaries (header data, no leaf rows). Each group's rows load on expand via `loadGroup`. In-memory mode.

  ```svelte
  <Grid rows={[]} {columns} height={480}
    lazyGroups={[{ key: 'HOSE', count: 412 }, { key: 'HNX', count: 330 }]}
    loadGroup={(key) => fetch(`/api/board/${key}`).then((r) => r.json())} />
  ```

- `loadGroup`: `(key: string) => Promise<GridRow[]>` — Load a lazy group's leaf rows on expand (returns a promise; shows a loading row, then caches). Required with `lazyGroups`.

  ```svelte
  <Grid rows={[]} {columns} height={480} {lazyGroups} loadGroup={(key) => loadExchange(key)} />
  ```

- `onRowReorder`: `(fromIndex: number, toIndex: number) => void` — Enable drag-to-reorder rows via a handle in the first column. Called with the from/to indices on drop — reorder your own `rows` in here. The indices are positions in the rows as shown, so they match `rows` while the list is neither sorted nor filtered. Off for grouped, paged, tree and `source` grids.

  ```svelte
  <Grid {rows} {columns} height={480}
    onRowReorder={(from, to) => { const next = [...rows]; next.splice(to, 0, ...next.splice(from, 1)); rows = next; }} />
  ```

- `pageSize`: `number` (default `0`) — Rows per page. When > 0 (in-memory mode), shows a pager instead of one long scroll; rows still virtualize within a page. `0` turns paging off.
- `pageSizeOptions`: `number[]` (default `undefined`) — Page-size choices for a dropdown in the pager. Picking one re-pages from page 0. Uncontrolled unless you also drive `pageSize` and update it from `onPageSizeChange`.

  ```svelte
  <Grid {rows} {columns} height={480} pageSize={50} pageSizeOptions={[25, 50, 100]} />
  ```

- `onPageSizeChange`: `(size: number) => void` — Called with the new page size when the pager's size dropdown changes.

  ```svelte
  <Grid {rows} {columns} height={480} {pageSize} pageSizeOptions={[25, 50, 100]} onPageSizeChange={(n) => (pageSize = n)} />
  ```

- `page`: `number` (default `undefined`) — Controlled current page (0-based). Omit for uncontrolled paging.
- `onPageChange`: `(page: number) => void` — Called with the new page index when the pager is used.

  ```svelte
  <Grid {rows} {columns} height={480} pageSize={50} {page} onPageChange={(p) => (page = p)} />
  ```

- `onColumnReorder`: `(keys: string[]) => void` — Called with the new column-key order after a header drag-reorder.

  ```svelte
  <Grid {rows} {columns} height={480} onColumnReorder={(keys) => save('order', keys)} />
  ```

- `onColumnResize`: `(key: string, width: number) => void` — Called with a column key + new width after a drag-resize. Only the dragged column is reported; `getState()` has every width.

  ```svelte
  <Grid {rows} {columns} height={480} onColumnResize={(key, width) => save(`width:${key}`, width)} />
  ```

- `ariaLabel`: `string` (default `undefined`) — Accessible name for the grid (`aria-label` on the `role="grid"` root).
- `filter`: `string` (default `''`) — Quick-search text: a row is kept when any column's value, as text, contains it (case-insensitive). Bind it to your own search input.
- `groupBy`: `string[]` (default `[]`) — Column keys to group rows by, outermost first (nested groups with subtotals). In-memory mode only.

  ```svelte
  <Grid {rows} {columns} height={480} groupBy={['account', 'sym']} />
  ```

- `aggregations`: `AggKind[]` (default `['sum', 'avg', 'count', 'min', 'max']`) — Which stats the selection footer shows over a multi-cell range.

  ```svelte
  <Grid {rows} {columns} height={480} aggregations={['sum', 'count']} />
  ```

- `persistKey`: `string` (default `undefined`) — Remember the user's layout (column order, widths, hidden and pinned columns, pinned rows) in localStorage under this key.
- `source`: `RowSource` (default `undefined`) — Back the grid with a windowed/server data source instead of `rows`. In source mode, sort + filter are delegated to the source; grouping is not applied.

  ```svelte
  <Grid rows={[]} {columns} height={480}
    source={{ getRows: ({ range, sorts, filter }) => fetchPage(range.start, range.end, sorts, filter) }} />
  ```

- `onCellEdit`: `(e: CellEditEvent) => void` — Called when an editable cell is committed. Update your row data in here.

  ```svelte
  <Grid {rows} {columns} height={480} onCellEdit={({ row, column, value }) => (row[column.key] = value)} />
  ```

- `cell`: `Snippet<[{ row: GridRow; column: ColumnDef; value: unknown }]>` — Render content for `type: 'custom'` columns.

  ```svelte
  <Grid {rows} {columns} height={480}>
    {#snippet cell({ row, column })}{#if column.key === 'chart'}<MiniChart sym={row.sym} />{/if}{/snippet}
  </Grid>
  ```


## Column options

- `key`: `string` — Field on the row to read for this column's value. Also the column's id: sort, filter, width and hidden state are keyed by it.
- `header`: `string` — The column's title in the header row.
- `locale`: `string` (default `undefined`) — BCP 47 locale for this column's built-in formatting (`price`, `date`, `currency`, `relative`). Unset, the grid's `locale`, then `en-US`.
- `value`: `(row: GridRow) => unknown` — Computed value: derive this cell's value from the whole row instead of reading `row[key]` (KPIs, ratios, deltas). Flows through display, sort, filter, aggregation, export and conditional formatting. `key` still names the column (and is the sort/filter key) but need not be a real row field. Keep it cheap and pure — it's called during sort/filter. Computed columns aren't editable. In-memory mode (a server source owns its own derivations).

  ```ts
  { type: 'percent', key: 'chg', header: 'Chg %', value: (row) => ((Number(row.last) - Number(row.ref)) / Number(row.ref)) * 100 }
  ```

- `spanRows`: `boolean | ((a: GridRow, b: GridRow) => boolean)` (default `false`) — Merge this column's cell down over adjacent rows. `true` joins rows with equal (non-blank) values; a function decides for each adjacent pair. Hierarchical: a run also breaks wherever a spanning column to its left breaks, and at group headers. A drawing rule only — sort, filter, copy and export still see every row's own value. In-memory mode only.

  ```ts
  { type: 'text', key: 'account', header: 'Account', spanRows: true }
  ```

- `colSpan`: `(row: GridRow) => number` — How many columns this cell covers for a given row (1 when unset). Covered cells are not drawn. Clamped at a pinned/scrolling boundary and the last column; the leftmost claim wins. Ignored under `virtualizeColumns`.

  ```ts
  { type: 'text', key: 'sym', header: 'Symbol', colSpan: (row) => (row.kind === 'note' ? 4 : 1) }
  ```

- `width`: `number` (default `96`) — Width in px. A `flex` column starts from it where columns have fixed widths (pinned columns, `virtualizeColumns`), and `fitColumns` scales columns in proportion to it.
- `minWidth`: `number` (default `48`) — Smallest width (px) a drag-resize or autosize can give this column; never less than 48. Under `fitColumns`, also the narrowest the column gets before the grid scrolls sideways (64 px without one, or the column's `width` if smaller).
- `maxWidth`: `number` (default `Infinity`) — Largest width (px) a drag-resize, an autosize, `autoWidth` or `fitColumns` can give this column.
- `flex`: `number` (default `undefined`) — flex-grow weight: the column stretches to fill the space the fixed-width columns leave, shared by weight. When the columns no longer fit, the grid scrolls sideways, counting a flex column as its `width`, else its `minWidth`, else 64 px. Unset, the column keeps its `width`.
- `align`: `Align` (default `'right' for number types, 'left' for the rest`) — Which side the header, cells and footer sit on. Set it to line a custom column's values up under its title.

  ```ts
  { type: 'custom', key: 'pnl', header: 'P&L', align: 'right' }
  ```

- `cellClass`: `string | ((value: unknown, row: GridRow) => string | undefined)` (default `undefined`) — Extra class(es) for this column's data cells — a static string, or a function of the cell value/row for conditional styling. Target via `:global(.bo-grid .c.your-class)`.

  ```ts
  { type: 'price', key: 'last', header: 'Last', cellClass: (v, row) => (Number(v) >= Number(row.ceiling) ? 'at-ceiling' : undefined) }
  ```

- `headerClass`: `string` (default `undefined`) — Extra class(es) for this column's header. Target via `:global`.
- `headerTooltip`: `string` (default `undefined`) — Show a styled tooltip on the column header (the same themed bubble as cell `tooltip`) — describe what the column means, units, caveats. Hovering anywhere on the header reveals it.
- `headerInfo`: `boolean` (default `false`) — Render a small ⓘ info icon in the header as a visible cue that a `headerTooltip` is available. Ignored without `headerTooltip`.
- `flash`: `boolean | 'auto' | 'up-down' | 'change'` (default `false`) — Flash the cell when its value changes. - `'auto'` (alias `'up-down'`) — the grid derives it: green on a rise, red on a fall, per cell. Nothing to maintain in app code; just stream new values in. This is what you want for a live price board. - `'change'` — a neutral flash on any change, numeric or not (status, qty). - `true` — legacy row-driven mode: the flash follows the row's own `flashSeq`/`flashDir`, so every `flash: true` column in a row flashes together, in one direction, and you bump the counter yourself. Derived modes never flash on first paint or when a row scrolls into view.
- `flashMs`: `number` (default `300`) — Flash duration in ms — **derived modes only** (`'auto'`/`'up-down'`/ `'change'`); has no effect on legacy `flash: true`, which flashes for the CSS animation's fixed duration regardless. Raise it for a slow feed, lower it for a fast one so flashes don't smear together.
- `flashColor`: `boolean` (default `true`) — Tint the text in the tick's direction while flashing. Set false when the column colours its own text — e.g. price-limit tones on a VN board (ceiling / floor / reference) — so the flash only lights the background.
- `showChange`: `boolean | ShowChangeOptions` (default `false`) — Show the size of each change beside the value for a moment — `▲0.15` in the up colour, `▼` down — held, then faded. The delta is the last change, written in the column's own format. Numeric values only; first paint and rows scrolling into view show nothing. `{ format, ms }` sets the text (`format(delta, row)` gets the signed change) and how long it shows (1000 ms unless set). In a narrow column the delta gives way first: the value itself is never cut for it. Meant for one or two key columns (the match price): each change runs a short animation, so a busy board with it on every column pays real frame time.

  ```ts
  { type: 'price', key: 'last', header: 'Last', flash: 'auto', showChange: { ms: 1500, format: (d) => `${d > 0 ? '+' : ''}${d.toFixed(2)}` } }
  ```

- `updateMs`: `number` (default `undefined`) — Repaint this column at most every this many ms under `api.patchRows`. Each tick is still written into the row at once (sort, filters, totals and other columns see it), but the cell shows the latest value on its next beat: a value that changes on every tick and is read at a glance (total volume, foreign buy and sell) stops repainting every frame. The first change after a quiet spell shows at once. A flash or `showChange` plays on the repaint, against the value shown before. For the field the column shows (`key`), not a `value` column; when several columns show a field, it waits only if they all set this. Unset, the cell repaints on every tick.
- `sortable`: `boolean` (default `true`) — Set false to disable header-click sorting on this column.
- `compare`: `(a: unknown, b: unknown) => number` — Custom ascending comparator for this column's values (e.g. enum priority or natural sort). Direction is applied by the grid. In-memory mode only.

  ```ts
  { type: 'badge', key: 'status', header: 'Status', compare: (a, b) => RANK[String(a)] - RANK[String(b)] }
  ```

- `groupAgg`: `AggKind` (default `undefined`) — When grouping is active (or with `footer`), show this aggregate of the column on group headers and the totals row.

  ```ts
  { type: 'volume', key: 'qty', header: 'Qty', groupAgg: 'sum' }
  ```

- `pinned`: `boolean | 'left' | 'right'` (default `false`) — Pin this column so it stays visible during horizontal scroll. `true` / `'left'` pins to the left edge, `'right'` to the right.
- `editable`: `boolean` (default `false`) — Allow inline editing (double-click, Enter or F2 on the focused cell, or type to replace). Number types edit as numbers, `date` as a date. Display types (`badge`, `boolean`, `rating`, `progress`, `tags`, `link`, `avatar`, `relative`, `sparkline`, `custom`) edit only with an `editor`; a computed (`value`) column never does.
- `validate`: `(value: unknown, row: GridRow) => boolean` — Validate an edited value before it commits (inline edit, custom editor, paste, fill). Return false to reject and keep the old value. Receives the parsed value.

  ```ts
  { type: 'number', key: 'qty', header: 'Qty', editable: true, decimals: 0, validate: (v) => Number(v) > 0 && Number(v) % 100 === 0 }
  ```

- `parse`: `(raw: string, row: GridRow) => unknown` — Turn typed or pasted text into the stored value (runs before the built-in number/date coercion, which it replaces). Return `undefined` or a non-finite number to reject the input.

  ```ts
  { type: 'number', key: 'qty', header: 'Qty', editable: true, parse: (raw) => Number(raw.replace(/[,\s]/g, '')) }
  ```

- `editor`: `Component<CellEditorProps>` — Svelte component that edits this cell in place of the built-in input. Makes any column type editable (with `editable: true`), including display widgets like `rating` or `tags`. It calls `commit(value)` to save — any value, no parsing — or `cancel()`; Escape cancels.

  ```ts
  { type: 'rating', key: 'score', header: 'Score', editable: true, editor: StarPicker }
  ```

- `options`: `string[]` (default `undefined`) — Editable choices: when set, editing renders a `<select>` of these options instead of a text input (enum/status columns).

  ```ts
  { type: 'text', key: 'side', header: 'Side', editable: true, options: ['Buy', 'Sell'] }
  ```

- `tooltip`: `boolean | ((value: unknown, row: GridRow) => string)` (default `false`) — Show a styled floating tooltip on each cell (themed, instant — replaces the native `title`). `true` shows the full formatted value (handy when content truncates); a function returns custom text per value/row (any column type, including `custom`). Return '' to suppress for a given cell.

  ```ts
  { type: 'text', key: 'sym', header: 'Symbol', tooltip: (_, row) => String(row.name ?? '') }
  ```

- `wrap`: `boolean` (default `false`) — Let long content wrap to multiple lines instead of truncating with an ellipsis. Pair with a taller `rowHeight`.
- `format`: `(value: unknown, row?: GridRow) => string` — Custom display formatter, overriding the built-in type formatter. Applies to display, tooltip, copy and (formatted) export. `row` is absent for aggregate cells.

  ```ts
  { type: 'number', key: 'qty', header: 'Qty', format: (v) => Number(v).toLocaleString('vi-VN') }
  ```

- `render`: `(ctx: CellRenderContext) => string | Node | null | undefined` — JS cell renderer — the framework-agnostic alternative to the `cell` snippet, for React/Vue/vanilla (and the `<bo-grid>` web component). Return an `HTMLElement`/`Node` (safe — appended as-is) or an HTML **string** (inserted as innerHTML — YOU must sanitize any untrusted data; prefer returning a Node for that). Overrides the cell's display only — sort, filter, tooltip, copy and export still use the value/`format`.

  ```ts
  { type: 'text', key: 'sym', header: 'Symbol', render: ({ value }) => Object.assign(document.createElement('b'), { textContent: String(value) }) }
  ```

- `component`: `Component<CellTypeProps>` — Svelte component that draws this cell (receives `value`, `row`, `column` and the formatted `text`). Display only, like `render`. Usually supplied by a registered `cellTypes` entry, but any column can set it. It updates in place as its props change; under `api.patchRows`, `row` is a fresh view of the patched row on each patch (same fields, new identity), so read it reactively (`$derived`, markup) rather than once at mount.

  ```ts
  { type: 'text', key: 'country', header: 'Country', component: CountryCell }
  ```

- `resizable`: `boolean` (default `true`) — Set false to disable drag-to-resize on this column.
- `autoWidth`: `boolean` (default `false`) — Widen the column when a value on screen no longer fits (a price gaining a digit, a running total), checked at most every 500 ms. It only grows, so values flickering wider and narrower don't make it jump; `maxWidth` caps it, and a width the user dragged or autosized takes over.
- `group`: `string` (default `undefined`) — Parent header label. Consecutive columns sharing a `group` render under a spanning header, which keeps the width of its columns (flex ones too).
- `filter`: `false | FilterKind | (string & {})` (default `'date' for date columns, 'number' for number types, else 'text'`) — Header filter-menu control for this column (requires `filterMenu` on <Grid>). `'set'` shows a value checklist, a registered `filterTypes` name draws that filter, and `false` disables filtering for this column.

  ```ts
  { type: 'badge', key: 'status', header: 'Status', filter: 'set' }
  ```

- `dataBar`: `DataBarConfig` (default `undefined`) — Conditional formatting — a horizontal bar painted behind the cell value, scaled across the column's value range. The range is auto-computed over the current view (in-memory) unless `min`/`max` are given; pass `min: 0` for absolute proportional bars. Bars cross a zero baseline when the range spans negatives. Numeric columns only.

  ```ts
  { type: 'volume', key: 'vol', header: 'Volume', dataBar: { min: 0 } }
  ```

- `icons`: `IconRule[]` (default `undefined`) — Conditional formatting — show an icon beside the value, chosen by the highest threshold `at` that is ≤ the cell value (e.g. ▲ ● ▼ by sign or band). Numeric columns only.

  ```ts
  { type: 'percent', key: 'chg', header: 'Chg %', icons: [{ at: -Infinity, icon: '▼', tone: 'down' }, { at: 0, icon: '●' }, { at: 0.01, icon: '▲', tone: 'up' }] }
  ```

- `colorScale`: `ColorScaleConfig` (default `undefined`) — Conditional formatting — tint the cell background across the column's value range (a soft heat ramp). Auto-ranged over the current view unless `min`/ `max` are given; pass `mid` for a 3-stop diverging scale (e.g. `mid: 0`). Numeric columns only.

  ```ts
  { type: 'percent', key: 'chg', header: 'Chg %', colorScale: { mid: 0 } }
  ```


## Column types

- `text`: `sub`
- `price`
- `percent`
- `volume`
- `number`: `decimals`
- `date`: `dateStyle`
- `currency`: `currency`, `decimals`
- `relative`
- `heatmap`: `min`, `max`, `decimals`
- `sparkline`: `sparkKey`
- `progress`: `min`, `max`
- `rating`: `max`
- `tags`
- `badge`: `tones`
- `boolean`: `trueLabel`, `falseLabel`
- `avatar`: `sub`
- `link`: `href`, `newTab`
- `custom`
- `(cellType)`: `cellType`

## Grid API

- `scrollToRow(key: string | number, align?: ScrollAlign): boolean` — Scroll a row into view by its id; `align` is `'nearest'` unless given. Returns false when the row is not in the current view (filtered out, not loaded, or unknown).

  ```ts
  api.scrollToRow(order.id, 'center');
  ```

- `focusCell(rowKey: string | number, columnKey: string): boolean` — Focus (and select) one cell by row id and column key. Returns false when the row or column is not currently visible.

  ```ts
  if (!api.focusCell(row.id, 'qty')) api.scrollToRow(row.id);
  ```

- `getFocusedCell(): { row: GridRow; column: ColumnDef } | null` — The focused cell's row and column, or null when no data cell has focus (nothing focused yet, or the focus is on a group row). For hotkeys that act on the row the user is on.

  ```ts
  const cell = api.getFocusedCell();
  if (cell) openTicket(cell.row);
  ```

- `getSelectedRows(): GridRow[]` — Ticked rows (needs `rowSelection`), in view order.

  ```ts
  const ids = api.getSelectedRows().map((row) => row.id);
  ```

- `autosizeColumns(keys?: string[]): void` — Fit columns to their content, as the column menu's Autosize does. All resizable columns when `keys` is omitted. The other columns keep their widths (flex ones are held where they are).

  ```ts
  api.autosizeColumns(['sym', 'name']);
  ```

- `exportCSV(filename?: string): Promise<void>` — Download the current view (after filter and sort) as CSV, named `export.csv` unless `filename` is given.

  ```ts
  await api.exportCSV(`orders-${new Date().toISOString().slice(0, 10)}.csv`);
  ```

- `getState(): GridState` — Snapshot of the user's layout: order, widths, hidden, pins, sorts, filters. A plain object, safe to `JSON.stringify` and store.

  ```ts
  localStorage.setItem('orders-layout', JSON.stringify(api.getState()));
  ```

- `applyState(state: unknown): boolean` — Restore a snapshot, reconciled against the columns as they are now. Returns false when the state is unusable (foreign version, not an object).

  ```ts
  const saved = localStorage.getItem('orders-layout');
  if (saved) api.applyState(JSON.parse(saved));
  ```

- `patchRows(patches: Iterable<readonly [string | number, Record<string, unknown>]>): number` — Realtime fast path: write `[rowId, fields]` patches into the rows in place and repaint only the rendered rows they change. Rows may be plain objects (no $state proxy) — far cheaper per write in a busy feed. Feed it a tick stream: `createTickStream({ apply: (b) => api.patchRows(b) })`. Sort and filter catch up on the next view change, as for any live data (or at once, with `liveSort`). A patch's id is the row's `getRowId` value (`row.id` unless set). Returns how many rows changed.

  ```ts
  api.patchRows([
    [1, { last: 68.6, vol: 1_204_300 }],
    [2, { last: 121.1 }],
  ]);
  ```

- `refresh(): void` — Re-run filter and sort against the rows' current values. Values that change in place (a feed) never re-sort the view by themselves — that would mean re-sorting on every tick — so call this when the order should catch up, e.g. every second on a "top movers" board (or use `liveSort`).

  ```ts
  const timer = setInterval(() => api.refresh(), 1000);
  ```

- `flashCells(opts?: FlashCellsOptions): number` — Flash cells on demand — a fill, an alert, a row to notice — with the tick animation, whether or not their values changed. Only rows on screen flash. Value cells flash (numbers, text); cells that draw their own content (badges, sparklines, components) do not. Returns how many rows it flashed.

  ```ts
  api.flashCells({ rows: [fill.orderId], dir: fill.side === 'buy' ? 'up' : 'down', ms: 800 });
  ```

- `pinRow(key: string | number, pinned?: boolean): boolean` — Pin a row above the scroll (or unpin it with `pinned: false`), as the row menu does under `rowPinning`; `pinned` is true unless given. The row stays in the body too. Returns false when no row has that id.

  ```ts
  api.pinRow(row.id);
  ```

- `getPinnedRowIds(): (string | number)[]` — Ids of the rows pinned at runtime, in pin order.

  ```ts
  for (const id of api.getPinnedRowIds()) api.pinRow(id, false);
  ```

- `openFind(query?: string): void` — Open the find bar (`findBar`), optionally searching for `query` at once.

  ```ts
  api.openFind('VNM');
  ```

