# bo-grid > A fast, lightweight data grid for trading screens: realtime price boards, order books, ladders, grouping, pivot and tree data. Native to Svelte 5, with React and Vue components and a custom element for everything else. MIT. Facts to get right before writing code against bo-grid: - Package: `bo-grid` on npm. Svelte 5 is a peer dependency; React and Vue are optional peers. - Svelte: `import { Grid, type ColumnDef, type GridRow } from 'bo-grid'`. Write Svelte 5 only: runes (`$state`, `$props`), snippets and callback props; never `export let`, `on:click` or ``. - React: `import { BoGrid } from 'bo-grid/react'`, its `ref` is the grid API. Vue: `import { BoGrid } from 'bo-grid/vue'`, props as attributes, callbacks as events (`@cell-edit`). Anything else: `bo-grid/element` (`createBoGrid(config)`). - Minimal usage: ``. Every row needs a unique `id` (or set `getRowId`). - A column's `type` picks how values show, sort and filter: `text`, `price`, `percent`, `volume`, `number`, `date`, `currency`, `heatmap`, `sparkline`, `progress`, `badge`, `tags` and more. - Live data: keep rows as plain objects and write ticks with `api.patchRows(patches)`, fed by `createTickStream` from `bo-grid/realtime`. A column with `flash: 'auto'` flashes every change by itself. - Separate entries, each paid for only when imported: `bo-grid/realtime`, `bo-grid/trading`, `bo-grid/ladder`, `bo-grid/react`, `bo-grid/vue`, `bo-grid/element`. - Every prop, column option and API method, with types and defaults: https://bonguynvan.github.io/bo-grid/api.json - The installed package carries these docs for its own version, one Markdown file per page: `node_modules/bo-grid/dist/docs/README.md`, with `api.json` beside it. In a project, `npx bo-grid init` writes a bo-grid section into AGENTS.md and installs the bo-grid skill, both pointing there. - An error from bo-grid starts with a stable code (`BG2004: ...`), states the value it got and ends with `Fix:` and a link. Apply the fix as written; every code is listed at https://bonguynvan.github.io/bo-grid/docs/errors/ # Introduction > bo-grid is a fast, lightweight data grid for trading screens. It is native to Svelte 5, with components for React and Vue and a custom element for every other framework. ## Why bo-grid - **Fast under load.** A 1,000-symbol price board at 30,000 ticks a second takes about 4.5 ms of main-thread work a frame (about 11 ms at p95), with every changed cell flashing ([how it's measured](https://github.com/bonguynvan/bo-grid/blob/main/BENCHMARKS.md)). - **Built for trading.** Tick flashes, price-limit colours, tick-aware formatting, a depth ladder with click-to-trade, order books, alerts, and a feed read in a worker. - **Complete.** Sorting, filtering, grouping, pivot, tree data, editing, clipboard and Excel export, all in one MIT package. - **Small and SSR-safe.** About 46 KB gzip for the grid, no runtime dependencies, and clean server rendering in SvelteKit. - **Accessible.** A full keyboard model with grid and treegrid semantics. ## What is in the package | Import | What it gives you | | --- | --- | | `bo-grid` | The `Grid` component, its column types and its API | | `bo-grid/realtime` | Live feeds: a coalescing tick stream, a feed read in a worker, an order book, alerts, rows that linger, a trade tape | | `bo-grid/trading` | Market conventions: price limits and tones, sessions, FX pips, crypto prices, basis points and 32nds, positions from fills | | `bo-grid/ladder` | `DepthLadder`, a price ladder with click-to-trade | | `bo-grid/react`, `bo-grid/vue` | The grid as a React or a Vue component | | `bo-grid/element` | The `` custom element, for Angular and plain JavaScript | Each entry is separate: an app pays only for what it imports. ## Where to next - [Installation](https://bonguynvan.github.io/bo-grid/docs/getting-started/): install the package and render a first grid in your framework. - [React, Vue & more](https://bonguynvan.github.io/bo-grid/docs/frameworks/): the components, the custom element, and server rendering. - [Examples](https://bonguynvan.github.io/bo-grid/examples/): every example runs live, with its source a tab away. ## With an AI coding agent bo-grid is a Svelte 5 library: in Svelte, write runes (`$state`, `$props`), snippets and callback props, never `export let`, `on:click` or ``. For an agent, every page here has a Markdown twin (add `.md` to its URL), [llms.txt](https://bonguynvan.github.io/bo-grid/llms.txt) indexes them all, and [api.json](https://bonguynvan.github.io/bo-grid/api.json) lists every prop with its type and default. [AI coding agents](https://bonguynvan.github.io/bo-grid/docs/ai/) has the details and a section to paste into your `AGENTS.md`. --- Page: https://bonguynvan.github.io/bo-grid/docs/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Quick start > Install bo-grid and render a first grid in Svelte, React, Vue or plain JavaScript. ## Install ```sh npm i bo-grid ``` Svelte 5 is a peer dependency. React and Vue are optional peers, needed only for `bo-grid/react` and `bo-grid/vue`. ## A first grid Give the grid rows (each with an `id`) and columns. A column's `type` picks how its values show: `price`, `percent`, `volume`, `date`, `heatmap`, `sparkline` and more. The tabs follow the framework chosen in the top bar. Svelte: ```svelte ``` React: ```tsx import { useState } from 'react'; import { BoGrid } from 'bo-grid/react'; import type { ColumnDef } from 'bo-grid'; const columns: ColumnDef[] = [ { type: 'text', key: 'symbol', header: 'Symbol' }, { type: 'price', key: 'price', header: 'Price', flash: 'auto' }, { type: 'percent', key: 'change', header: 'Chg %' }, { type: 'volume', key: 'volume', header: 'Volume' }, ]; export function Board() { const [rows] = useState([ { id: 1, symbol: 'VNM', price: 68.5, change: 1.2, volume: 1_204_000 }, { id: 2, symbol: 'FPT', price: 132, change: -0.4, volume: 860_500 }, ]); return ; } ``` Vue: ```vue ``` JavaScript: ```js import { createBoGrid } from 'bo-grid/element'; /** @type {import('bo-grid').ColumnDef[]} */ const columns = [ { type: 'text', key: 'symbol', header: 'Symbol' }, { type: 'price', key: 'price', header: 'Price', flash: 'auto' }, { type: 'percent', key: 'change', header: 'Chg %' }, { type: 'volume', key: 'volume', header: 'Volume' }, ]; const rows = [ { id: 1, symbol: 'VNM', price: 68.5, change: 1.2, volume: 1_204_000 }, { id: 2, symbol: 'FPT', price: 132, change: -0.4, volume: 860_500 }, ]; const config = { columns, rows, height: 400 }; const grid = createBoGrid(config); document.querySelector('#board')?.append(grid); // Later: grid.config = { ...config, rows: nextRows }; ``` The same grid, running: sort a column from its header, resize one from its edge, or select a range to see its sum. Example: A first grid (quick-start.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` The three props every grid takes: - `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 ``` ## A live feed For a busy feed, keep the rows as plain objects and let the grid write the ticks. `createTickStream` coalesces messages per key and hands the grid one batch a frame; `patchRows` repaints only the cells that changed. Svelte: ```svelte String(r.symbol)} onReady={(a) => (api = a)} height={400} /> ``` React: ```tsx import { useEffect, useRef } from 'react'; import { BoGrid } from 'bo-grid/react'; import { createTickStream } from 'bo-grid/realtime'; import type { GridApi } from 'bo-grid'; export function Board({ initialQuotes }: { initialQuotes: Quote[] }) { const grid = useRef(null); useEffect(() => { const stream = createTickStream>({ apply: (batch) => grid.current?.patchRows(batch), }); stream.start(); socket.onmessage = (e) => { const q = JSON.parse(e.data); stream.push(q.symbol, q); }; return () => stream.stop(); }, []); return String(r.symbol)} height={400} />; } ``` Vue: ```vue ``` Past about 10,000 messages a second, read the socket in a worker with `serveFeed` and `connectFeed`: the main thread then takes one batch a frame instead of every message. ## Next - [React, Vue & more](https://bonguynvan.github.io/bo-grid/docs/frameworks/): props, the API on a ref, and server rendering. - [Price board](https://bonguynvan.github.io/bo-grid/examples/priceboard/): a full board under a busy feed, live. - [Examples](https://bonguynvan.github.io/bo-grid/examples/): every feature, live, with its source. --- Page: https://bonguynvan.github.io/bo-grid/docs/getting-started/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # React, Vue & more > The grid as a React or a Vue component, the custom element for Angular and plain JavaScript, and server rendering in each. bo-grid is a Svelte 5 component. In Svelte, import `Grid` from `bo-grid`: it is the smallest build, and the only one that takes Svelte snippets for custom cells. For React and Vue there are typed components, and for everything else a custom element. All three take the same props and expose the same [grid API](https://bonguynvan.github.io/bo-grid/docs/grid-api/). | You write | Import | API handle | | --- | --- | --- | | Svelte 5 | `import { Grid } from 'bo-grid'` | `onReady` | | React 18 or 19 | `import { BoGrid } from 'bo-grid/react'` | the component's `ref` | | Vue 3.3+ | `import { BoGrid } from 'bo-grid/vue'` | the template ref's `api` | | Angular, plain JS | `import 'bo-grid/element'` | `onReady` in the element's `config` | Build-free starters for [plain JavaScript](https://github.com/bonguynvan/bo-grid/blob/main/examples/vanilla.html), [React](https://github.com/bonguynvan/bo-grid/blob/main/examples/react.html) and [Vue](https://github.com/bonguynvan/bo-grid/blob/main/examples/vue.html) are in the repository's `examples` folder: run `npx serve examples` and open one. ## React `bo-grid/react` takes every `` prop as a React prop; its `ref` is the grid's API. ```tsx import { useEffect, useRef } from 'react'; import { BoGrid } from 'bo-grid/react'; import type { GridApi } from 'bo-grid'; export function Board({ rows }: { rows: Quote[] }) { const grid = useRef(null); // A live feed: patch the rows in place, with no React re-render. useEffect(() => feed.subscribe((ticks) => grid.current?.patchRows(ticks)), []); return ; } ``` - It loads the `` element on the client, so server rendering (Next.js, Remix) renders its wrapper div and nothing else, and hydration matches. The bundle carries `'use client'` for server components. - `className` and `style` go on that wrapper div, which keeps the grid's `height` until the element loads, so nothing jumps. - Each render sets the element's config from the props. Unchanged arrays and functions are not reprocessed, but a new `rows` array is a new view: for a live feed, patch through the ref (`patchRows`, `flashCells`) instead of passing new rows. ## Vue `bo-grid/vue` takes every `` prop as an attribute (`:rows`, `:row-height`, a bare `footer`) and every callback as an event (`@cell-edit` is `onCellEdit`); the template ref's `api` is the grid's API. ```vue ``` - Server rendering (Nuxt) renders the wrapper div only, and hydration matches; no `isCustomElement` setting is needed. - A bare boolean attribute is true (``), and an event bound more than once calls every listener. - The grid sees a new `rows` array, not changes inside one (a `push`, or a field set on a row). Keep the rows in a `shallowRef` and assign a new array, or patch through the API. - `class` and `style` go on the wrapper div, which keeps the grid's `height` until the element loads. ## Angular and plain JavaScript `bo-grid/element` registers `` and takes the whole grid API through one `config` property. Arrays and functions cannot be HTML attributes, so set it from code; assigning a new `config` re-renders. ```ts import { createBoGrid } from 'bo-grid/element'; const config = { columns, rows, height: 520, onCellEdit: save }; const grid = createBoGrid(config); document.querySelector('#board')?.append(grid); grid.config = { ...config, rows: nextRows }; // a new config re-renders ``` In Angular, add `CUSTOM_ELEMENTS_SCHEMA` to the component and bind the property: ```ts nocheck import 'bo-grid/element'; import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; @Component({ selector: 'app-board', schemas: [CUSTOM_ELEMENTS_SCHEMA], template: '', }) export class BoardComponent { config = { columns, rows, height: 520 }; } ``` - The entry ships TypeScript types: `BoGridConfig` (every `` prop), `BoGridElement` (an `HTMLElement` with a typed `config`) and `createBoGrid(config)`, which sets the config before the element is attached. - Setting `config` after the element is attached is safe too: it renders an empty grid until the config arrives. - The grid's API arrives through `onReady` in the config, once the grid is ready. - The element renders to the light DOM, so your `--bo-grid-*` CSS variables and page styles apply, and it injects its own styles: there is no stylesheet to add. - It carries the Svelte runtime, so it is larger: about 87 KB gzip, against about 46 KB for the grid imported in Svelte. - It needs the DOM: in a server-rendered app, import it on the client. ## Custom cells outside Svelte Svelte snippets cannot be passed from JavaScript, so the `cell` and `detail` snippets are Svelte-only. For rich cells, give a column a `render({ value, row, column })` function that returns a DOM node or an HTML string; built-in [column types](https://bonguynvan.github.io/bo-grid/docs/column-types/) and `format` cover most cases without one. ```js /** @type {import('bo-grid').ColumnDef[]} */ const columns = [ { type: 'custom', key: 'status', header: 'Status', render: ({ value }) => { const dot = document.createElement('span'); dot.textContent = value === 'up' ? '● Live' : '○ Idle'; dot.style.color = value === 'up' ? 'var(--bo-up)' : 'var(--bo-text-dim)'; return dot; }, }, ]; ``` Sorting, filtering, tooltips, copy and export still use the value and `format`; `render` only changes how the cell looks. See [Custom cells](https://bonguynvan.github.io/bo-grid/docs/custom-cells/#a-render-function). ## Live feeds and trading helpers `bo-grid/realtime` and `bo-grid/trading` are plain JavaScript with no framework in them, so they import the same way everywhere. A tick stream coalesces messages per key and hands the grid one batch a frame: ```js import { createBoGrid } from 'bo-grid/element'; import { createTickStream } from 'bo-grid/realtime'; /** @type {import('bo-grid').GridApi | undefined} */ let api; const grid = createBoGrid({ columns, rows, height: 520, getRowId: (r) => String(r.symbol), onReady: (a) => (api = a) }); document.querySelector('#board')?.append(grid); const stream = createTickStream({ apply: (batch) => api?.patchRows(batch) }); stream.start(); socket.onmessage = (e) => { const q = JSON.parse(e.data); stream.push(q.symbol, q); // cheap: coalesces per symbol, no render }; ``` In React the API is the component's `ref` (`grid.current`), and in Vue the template ref's `api`. The feed's key must match the rows' identity: set `getRowId`, as here, when the feed keys rows by something other than `id`. See [Tick streams](https://bonguynvan.github.io/bo-grid/docs/tick-stream/) and [Market conventions](https://bonguynvan.github.io/bo-grid/docs/trading/). ## Reference Every prop on this page is a `` prop: see the [Props reference](https://bonguynvan.github.io/bo-grid/docs/reference/). --- Page: https://bonguynvan.github.io/bo-grid/docs/frameworks/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # SvelteKit & SSR > Use bo-grid in SvelteKit with no setup, render the grid on the server, load rows in a load function, and add a live feed in the browser. bo-grid renders on the server: `` produces its HTML without touching `window`, `document` or `localStorage`, then hydrates in the browser. There is no `browser` guard, no client-only import and no stylesheet to add. A CI check (`pnpm ssr`) server-renders the grid on every build to keep it that way. ## Install ```sh npm i bo-grid ``` Svelte 5 is a peer dependency, already in a SvelteKit app. Excel export is optional: install `xlsx` only if you call `exportXLSX`. It loads as a separate chunk the first time you export, so it never weighs on your main bundle. ## A first page ```svelte ``` No `onMount` and no `{#if browser}`: the table is in the server-rendered HTML, which helps first paint and crawlers. ## Rows from a load function Fetch the rows on the server, or in a universal `load`, and pass them in. Keep the column definitions in the component: ```ts // src/routes/+page.server.ts import type { PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ fetch }) => { const res = await fetch('https://api.example.com/quotes'); return { rows: await res.json() }; }; ``` ```svelte ``` The import helpers are plain functions with no DOM, so they work in `load` too. `rowsFromObjects` gives each object an `id` (its index, unless it has one), and `parseCSV` turns CSV text into rows typed by your columns: ```ts import { parseCSV, rowsFromObjects } from 'bo-grid'; import type { PageLoad } from './$types'; export const load: PageLoad = async ({ fetch }) => { const quotes = rowsFromObjects(await (await fetch('/api/quotes')).json()); const history = parseCSV(await (await fetch('/data/history.csv')).text(), columns); return { quotes, history }; }; ``` ## A live feed in the browser The grid renders on the server, but a feed is a browser concern: open the socket in `onMount`. Keep the rows as plain objects, write each batch of ticks with `api.patchRows`, and let columns with `flash: 'auto'` flash every change: ```svelte (api = a)} height={640} /> ``` `patchRows` changes fields of existing rows. To add, remove or reorder rows, assign a new array: `rows = [...rows, newRow]`. See [Live updates](https://bonguynvan.github.io/bo-grid/docs/realtime/) and [Tick streams](https://bonguynvan.github.io/bo-grid/docs/tick-stream/). ## Saved layouts `persistKey` saves the user's column order, widths, hidden and pinned columns in `localStorage`. It is safe to set unconditionally: on the server it does nothing, and the layout comes back in the browser. ```svelte ``` ## Large and server-backed data For data too large to send at once, back the grid with a `RowSource` that fetches windows of rows, or load tree children and groups when they are expanded. All of these are `fetch`-based, so they fit SvelteKit's data loading, and the first page can still be server-rendered from `load`: ```svelte fetch(`/api/orders?group=${key}`).then((r) => r.json())} height={560} /> ``` See [Server-side data](https://bonguynvan.github.io/bo-grid/docs/server-data/), [Tree data](https://bonguynvan.github.io/bo-grid/docs/tree-data/) and [Server-side grouping](https://bonguynvan.github.io/bo-grid/docs/server-grouping/). ## Printing and export Printing and export are user actions: call them from an event handler. `printTable` opens the browser's print window, and the export helpers download a file: ```svelte ``` See [Export & import](https://bonguynvan.github.io/bo-grid/docs/export/). ## Notes - **Styles** come with the component; there is nothing to import. Change the look with the `theme` prop or `--bo-grid-*` CSS variables (see [Theming](https://bonguynvan.github.io/bo-grid/docs/theming/)). - **Vite** needs no special configuration. If Vite warns about optimizing `xlsx`, install it or add it to `optimizeDeps.exclude`. - **Use `Grid`, not the custom element.** In SvelteKit, import the `Grid` component: it is the smallest build and the only one that takes snippets. `bo-grid/element` is for apps that are not written in Svelte (see [React, Vue & more](https://bonguynvan.github.io/bo-grid/docs/frameworks/)). --- Page: https://bonguynvan.github.io/bo-grid/docs/sveltekit/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # AI coding agents > Give a coding agent what it needs to write correct bo-grid code first time, with every page as Markdown, the docs inside the package, an index in llms.txt, an API manifest generated from the types, and rules for your AGENTS.md. A coding agent works differently from a person reading the docs. It may remember an older version of a library, or a similar one; it cannot see what the grid looks like; and it learns by running code, failing and fixing. So what helps it most is accurate text it can fetch, types it can check against, and complete examples it can copy. bo-grid publishes all three, generated from the same sources as these pages. | What | Where | Use it for | | --- | --- | --- | | Index of the docs | [`/llms.txt`](https://bonguynvan.github.io/bo-grid/llms.txt) | The facts to get right first, and a link to every page as Markdown | | Any page as Markdown | add `.md` to its URL: [`/docs/grouping.md`](https://bonguynvan.github.io/bo-grid/docs/grouping.md) | Feeding one topic to an agent | | Copy page | the button at the top of every docs page | Pasting a page into a chat | | API manifest | [`/api.json`](https://bonguynvan.github.io/bo-grid/api.json) | Every prop, column option and API method, with types, defaults and docs | | Full reference | [`/llms-full.txt`](https://bonguynvan.github.io/bo-grid/llms-full.txt) | The whole API in one file | | Project setup | `npx bo-grid init` | Writes the bo-grid section into your `AGENTS.md` and installs the bo-grid skill | | Docs in the package | `node_modules/bo-grid/dist/docs/` | Every page, the reference and `api.json` of the version installed, with no network | | Types | `node_modules/bo-grid/dist/**/*.d.ts` | The API of the exact version installed | ## Every page as Markdown Each docs page has a plain Markdown twin at the same URL with `.md` in place of the trailing slash: `/docs/row-selection/` is also `/docs/row-selection.md`. The twin is generated from the page's source when the site is built, and it is self-contained: - Props tables become lists of each prop with its type, default and description. - Small examples are inlined as complete Svelte files, imports included. - Gallery examples become links to their live page and source. The **Copy page** button at the top of every page copies that Markdown, ready to paste into an agent. Each page also declares it in its HTML head (``), so a tool that reads the page can find it. ## Docs in the package The npm package carries the docs of its own version in `node_modules/bo-grid/dist/docs/`. An agent working in your project can read them without fetching anything, and they match what is installed, even after an upgrade: - `README.md` is the index: the same facts as llms.txt, then every page as a link to the file next to it. - Each docs page is a Markdown file with the same text as its `.md` twin. Links between pages point at the local files. - `reference.md` and `api.json` list every prop, column option and API method. ## llms.txt [`/llms.txt`](https://bonguynvan.github.io/bo-grid/llms.txt) is the entry point for an agent that fetches URLs. It opens with the facts most often gotten wrong, such as which import a feature comes from and the Svelte 5 syntax to use, then lists every docs page, in sidebar order, as a Markdown link with a one-line summary. It is generated from the sidebar at build time, so it lists exactly the pages that exist. ## The API as data: api.json [`/api.json`](https://bonguynvan.github.io/bo-grid/api.json) describes every `` prop, every column option (shared ones, and the options each column type adds), and every grid API method. It is generated from the TypeScript sources and their doc comments, so it matches the release the site was built from: ```json { "package": "bo-grid", "grid": { "props": [ { "name": "rowHeight", "type": "number | ((row: GridRow, index: number) => number)", "required": false, "doc": "Row height in px (uniform), or a function for variable heights (in-memory mode only).", "default": "36", "example": " (row.note ? 56 : 36)} />" } ] } } ``` Every optional prop and option states its `default`, and every one whose type is more than a plain value has an `example`, as does every API method. The build fails when one is missing, so the file is complete for each release. The props tables in these docs, such as the one on the [Props reference](https://bonguynvan.github.io/bo-grid/docs/reference/), are drawn from the same file. A docs page that names a prop which does not exist fails the site's build, so the docs cannot describe an API the types don't have. ## Types are the first docs An agent working in your project can read the package's type definitions in `node_modules`, and those always match the installed version, whatever the agent remembers. bo-grid's types are written to be read: - `ColumnDef` is a union on `type`, so an option on the wrong column type (`decimals` on a `badge`, say) is a type error, not a silent no-op. - Props, column options and API methods carry doc comments with `@default` and `@example` tags, the same text as in `api.json`. An editor shows them on hover, and an agent reading the `.d.ts` files finds a default and a working example beside each declaration. - Values are narrow where they can be: `flash` is `boolean | 'auto' | 'up-down' | 'change'`, not `string`. So the most useful habit to give an agent is to run the type check after each change (`svelte-check` in a Svelte project, `tsc` elsewhere) and fix what it reports. ## Errors that say the fix When the grid is used wrongly at run time, the error it throws is written for whoever reads it next, person or agent. It starts with a stable code, says what happened with the value the grid got, and ends with the fix and a link: ```text BG3003: createPositions: a fill's qty must be a number above 0, got -100. Fix: Pass the filled quantity as a positive number; the side says which way (side: 'sell', not a negative qty). See https://bonguynvan.github.io/bo-grid/docs/errors/#bg3003 ``` An agent can apply the fix without reading the library's source, and a test or an error reporter can match on `error.code`. Every code is on the [Error codes](https://bonguynvan.github.io/bo-grid/docs/errors/) page, which a test keeps in step with the source. ## Set up a project in one command In your project, run: ```sh npx bo-grid init ``` It writes two files that a coding agent reads without being told: - **`AGENTS.md`** gets the bo-grid section shown [below](#add-bo-grid-to-your-agents-md), between two `bo-grid` marker comments. The rest of the file stays as it is, and the file is created if there is none. - **`.claude/skills/bo-grid/SKILL.md`** is the bo-grid skill: the same rules, two complete examples (a first grid, a live price board) and the workflow (read the page, write, type-check). Claude Code loads skills from that folder when a task calls for one; for another agent, point `--skills-dir` at the folder it reads skills from. Run it again after you upgrade bo-grid: it brings both files up to date. In `AGENTS.md` it replaces only its own section; the skill file is bo-grid's, and is replaced whole, so keep your own notes elsewhere. When it cannot place the section safely, it leaves the file alone and says why: a bo-grid section you pasted from these docs (so the file never gets two), or markers that don't pair up. | Option | What it does | | --- | --- | | `--file ` | Writes the section into another file instead, such as `CLAUDE.md`. Repeat it for more than one. | | `--no-skill` | Skips the skill. | | `--skills-dir ` | Installs the skill under another folder (default `.claude/skills`). | | `--cwd ` | Runs on another folder, such as one app in a monorepo. | | `--dry-run` | Reports what would change, and writes nothing. | | `--json` | Prints the result as JSON, for scripts and agents. | It exits with 0 when it is done, 1 when it left a file alone or could not write one, and 2 on bad usage. Claude Code reads `CLAUDE.md`: if yours does not import `AGENTS.md`, init says so, and you can add the line `@AGENTS.md` to it or run init with `--file CLAUDE.md`. ## Add bo-grid to your AGENTS.md Most coding agents read a project instruction file: `AGENTS.md`, `CLAUDE.md`, or a rules file. `npx bo-grid init` writes this section into yours; to add it by hand, paste it: ```md ## bo-grid (data grid) This project uses `bo-grid`. Before writing grid code, read node_modules/bo-grid/dist/docs/README.md and the page for the feature: the docs of the installed version, one Markdown file per page (online: https://bonguynvan.github.io/bo-grid/llms.txt). Every prop, column option and API method, with types and defaults, is in node_modules/bo-grid/dist/docs/api.json. Never invent a prop: if it is not in api.json or the types in node_modules/bo-grid, it does not exist. - Svelte 5 only: runes (`$state`, `$derived`, `$props`), snippets and callback props. Never `export let`, `on:click` or ``. - Imports: `Grid` and types from `bo-grid`; feeds from `bo-grid/realtime`; market helpers from `bo-grid/trading`; `DepthLadder` from `bo-grid/ladder`; React `BoGrid` from `bo-grid/react`; Vue `BoGrid` from `bo-grid/vue`; the custom element from `bo-grid/element`. - Minimal grid: ``. Every row needs a unique `id` (a number or a string), or pass `getRowId`. - Live prices: a column with `flash: 'auto'` flashes changes by itself. Write ticks with `api.patchRows(patches)` (the API comes from `onReady`), fed by `createTickStream`. Don't write `flashSeq` or `flashDir` bookkeeping. - To add, remove or reorder rows, assign a new array; `patchRows` only changes fields of rows that exist. - Outside Svelte there are no snippets: draw custom cells with a column's `render(ctx)` function. - After editing, run the type check (`svelte-check` or `tsc`) and fix errors. - A runtime error that starts with a `BG` code ends with `Fix:`; apply it as written. ``` The repository's [`docs/ai`](https://github.com/bonguynvan/bo-grid/tree/main/docs/ai) folder has the same guidance as a [Cursor rule](https://github.com/bonguynvan/bo-grid/blob/main/docs/ai/cursor-rules.mdc) (save it as `.cursor/rules/bo-grid.mdc`) and a longer [CLAUDE.md section](https://github.com/bonguynvan/bo-grid/blob/main/docs/ai/claude-md-snippet.md). ## Examples are complete files Every example on this site is a whole Svelte file, and its **Code** tab shows exactly the file that runs. The small examples in these pages import from `bo-grid` just as your app does, with no site-only helpers, so an agent can copy one, run it, and change it. The [Examples](https://bonguynvan.github.io/bo-grid/examples/) gallery has larger, realistic screens (a price board, an order book, a blotter), each on its own page with its source. The code blocks in these pages, and the `@example` of every prop and API method, are type-checked against the library on every change, in strict TypeScript. An excerpt is checked the way you would read it: with the imports it leaves out and the app around it (your rows, your socket) filled in with real types. So what you copy compiles, and it shows the habits strict TypeScript asks for: row values are `unknown` until you convert them (`Number(row.last)`), and a helper such as `createAlerts` is told your row type. ## Prompts that work Point the agent at the docs first, and name the feature's page when you know it: - "Read node_modules/bo-grid/dist/docs/README.md. Then build a watchlist with bo-grid: symbol, price with `flash: 'auto'`, change % and volume, fed from the WebSocket in `src/lib/feed.ts`." - "Using https://bonguynvan.github.io/bo-grid/docs/grouping.md, group the orders grid by account and show the summed quantity on each group row." - "Check every prop in `OrdersGrid.svelte` against https://bonguynvan.github.io/bo-grid/api.json and fix any that don't exist." --- Page: https://bonguynvan.github.io/bo-grid/docs/ai/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Column definitions > Describe each column once, with a key, a header and a type, and the grid handles display, sorting, filtering and export. A grid takes `columns`, an array of column definitions in display order. Each definition names the row field to read (`key`), the title to show (`header`), and a `type` that decides how the value is formatted, aligned, sorted and filtered. Everything else, such as width, pinning, tooltips and editing, is an optional field on the same object. Sort a column from its header, hover a cut-off value for its tooltip, or drag a header edge to resize: Example: Column definitions (columns.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## The shape of a column ```svelte ``` - **`key`** is the row field the column reads. It is also the column's identity: sorting, filtering, hiding and saved layouts refer to columns by key, so keep keys unique. - **`header`** is the title in the header row. - **`type`** picks the cell's format and behaviour. `ColumnDef` is a union on `type`, so TypeScript checks each type's own options (`decimals` on `number`, `min` and `max` on `heatmap`, `currency` on `currency`). See [Column types](https://bonguynvan.github.io/bo-grid/docs/column-types/) for the full list. - Rows are plain objects with a unique `id`: a number, or a string such as a symbol or UUID. If your data has another identity field, pass `getRowId` instead of copying it into `id`. ## Widths A column is either fixed or flexible: - **`width`** sets a fixed width in pixels. - **`flex`** sets a grow weight: flex columns share the width that fixed columns leave over, in proportion to their weights. A column with `flex` ignores `width`. - **`minWidth`** and **`maxWidth`** bound what a drag-resize can do. To make every column scale with the grid instead, set `fitColumns` on the grid. [Column sizing](https://bonguynvan.github.io/bo-grid/docs/column-sizing/) covers resizing, `fitColumns` and `autoWidth`. ## Alignment, classes and formatting Number types line up on the right and the rest on the left. Set `align` to override that for one column, for example a custom column that holds numbers. `format(value, row)` replaces the built-in formatter. It applies wherever the value is shown as text: the cell, its tooltip, copy and formatted export. ```ts { type: 'number', key: 'ratio', header: 'Ratio', format: (v) => `${Number(v).toFixed(1)}×` } ``` `cellClass` and `headerClass` add your own classes. `cellClass` can be a function of the value and the row. Rows render inside the grid, so target the classes with `:global(...)`; see [Row styles](https://bonguynvan.github.io/bo-grid/docs/row-styles/). ## Defaults for every column `defaultColumn` sets fields that every column starts from. A column's own fields, and a registered `cellType`, win over it: ```svelte ``` To reuse a whole kind of column across grids, such as a money amount or an order status, register it once with `cellTypes`: see [Registered cell types](https://bonguynvan.github.io/bo-grid/docs/column-types/#registered-cell-types). ## Locale Built-in number, price, currency and date formatting uses `en-US` by default. Set the grid's `locale` to change it for every column, or a column's own `locale` to change one: ```svelte ``` [Localization](https://bonguynvan.github.io/bo-grid/docs/localization/) covers the grid's own strings too. ## Reference - `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. - `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. - `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`. - `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. - `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' } ``` - `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') } ``` - `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`. - `sortable`: `boolean` (default `true`) — Set false to disable header-click sorting on this column. - `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`. - `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' }, ]; ``` - `defaultColumn`: `DefaultColumn` (default `undefined`) — Settings applied under every column — its own fields (and a registered `cellType`) win. ```svelte ``` - `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 `${row.account}:${row.sym}`} /> ``` - `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. --- Page: https://bonguynvan.github.io/bo-grid/docs/columns/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Column types > Pick a column type for each column, from prices and percentages to badges, ratings and progress bars, or register your own. A column's `type` decides how its values look and behave: the number format, the alignment, how it sorts and which filter it offers. Data types format numbers, dates and money. Rich types draw the value as a small widget. When none fits, register a type of your own with `cellTypes`, or draw the cell yourself with [custom cells](https://bonguynvan.github.io/bo-grid/docs/custom-cells/). Every column below is a built-in type. Sort any of them: each sorts by its plain value, not by the widget. Example: Rich column types (column-types.svelte) ```svelte ``` team-data.ts: ```ts // A small team list for the docs' rich-cell examples. export type Member = { id: number; name: string; role: string; email: string; status: 'Active' | 'Away' | 'Offline'; workload: number; rating: number; skills: string[]; }; /** A fresh copy of the team, so one example's edits never reach another. */ export function team(): Member[] { return [ { id: 1, name: 'Ava Chen', role: 'Engineer', email: 'ava@acme.io', status: 'Active', workload: 82, rating: 5, skills: ['TypeScript', 'Svelte'] }, { id: 2, name: 'Liam Patel', role: 'Designer', email: 'liam@acme.io', status: 'Away', workload: 46, rating: 4, skills: ['Figma', 'CSS'] }, { id: 3, name: 'Mia Kim', role: 'PM', email: 'mia@acme.io', status: 'Active', workload: 64, rating: 4, skills: ['Roadmaps'] }, { id: 4, name: 'Noah Garcia', role: 'Analyst', email: 'noah@acme.io', status: 'Offline', workload: 21, rating: 3, skills: ['SQL', 'Python'] }, { id: 5, name: 'Zoe Nguyen', role: 'Engineer', email: 'zoe@acme.io', status: 'Active', workload: 93, rating: 5, skills: ['Rust', 'Go'] }, { id: 6, name: 'Ravi Haddad', role: 'Researcher', email: 'ravi@acme.io', status: 'Away', workload: 38, rating: 2, skills: ['Data viz'] }, { id: 7, name: 'Yuki Rossi', role: 'Writer', email: 'yuki@acme.io', status: 'Active', workload: 57, rating: 4, skills: ['Docs', 'CSS'] }, ]; } ``` ## Data types | Type | Value | Shows | | --- | --- | --- | | `text` | string | Text; `sub` names a field shown as a second, dimmer line | | `price` | number | A price with two decimals, in the column's locale | | `percent` | number | A signed percentage (`+1.20%`), coloured up or down | | `volume` | number | A compact count: 1.2K, 3.4M, 1.1B, 2.0T | | `number` | number | A number with `decimals` places (default 2) | | `currency` | number | Money in `currency` (default USD), through `Intl.NumberFormat` | | `date` | epoch ms (or a `Date`) | A date; `dateStyle` is `'short'` or `'medium'` (default) | | `relative` | epoch ms | A relative time: "3 hours ago" | | `heatmap` | number | The value on a colour ramp from `min` to `max` | | `sparkline` | candles | A small chart drawn from the row field named by `sparkKey` | ## Rich types | Type | Value | Shows | | --- | --- | --- | | `avatar` | display name | Initials in a circle, with an optional `sub` line | | `badge` | string | A pill; `tones` maps values to `up`, `down`, `amber`, `info` or `neutral` | | `progress` | number | A bar from `min` to `max` | | `rating` | number | Stars out of `max` | | `tags` | string array, or a comma-separated string | Chips | | `boolean` | boolean | `trueLabel` or `falseLabel` | | `link` | text | A link to `href(row)`; `newTab` opens a new tab | | `custom` | anything | Whatever the grid's `cell` snippet draws | ```ts const columns: ColumnDef[] = [ { type: 'avatar', key: 'name', header: 'Member', sub: 'role' }, { type: 'badge', key: 'status', header: 'Status', tones: { Active: 'up', Away: 'amber', Offline: 'neutral' } }, { type: 'progress', key: 'workload', header: 'Workload', min: 0, max: 100 }, { type: 'rating', key: 'rating', header: 'Rating', max: 5 }, { type: 'tags', key: 'skills', header: 'Skills' }, { type: 'boolean', key: 'remote', header: 'Where', trueLabel: 'Remote', falseLabel: 'Office' }, { type: 'link', key: 'email', header: 'Email', href: (r) => `mailto:${r.email}` }, ]; ``` - `link` blocks unsafe hrefs (`javascript:` and `data:` URLs). - Rich types take their colours from the theme, so they follow light and dark themes with no extra code. - A widget is only how a value is drawn: sorting, filtering, copying and export use the value itself. ## Registered cell types When the same kind of column appears in several grids, such as a country, a money amount or an order status, define it once and give it a name. `cellTypes` maps a name to column defaults, and a column that says `cellType: 'name'` gets them under its own fields: ```svelte ``` - **`extends`** names the built-in type the new one builds on: its formatting, sorting, filtering, alignment, aggregation and export. It defaults to `custom` when the entry draws the cell itself (`component` or `render`), and to `text` otherwise. - An entry can set any column field: `format`, `compare`, `align`, `filter`, `width`, `cellClass`, `tooltip`, `editable`, and type options such as `decimals`. - An unknown `cellType` renders as plain text rather than failing. - Outside the grid, `resolveColumns(columns, cellTypes)` returns the same resolved columns, for `toCSV`, `printTable` or `pivot`. The **Team** example registers a `country` type with a flag component: see [Custom cells](https://bonguynvan.github.io/bo-grid/docs/custom-cells/). ## Reference - `cellTypes`: `Record` (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 Number(v).toLocaleString() } }} /> ``` - `defaultColumn`: `DefaultColumn` (default `undefined`) — Settings applied under every column — its own fields (and a registered `cellType`) win. ```svelte ``` - `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. - `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') } ``` - `component`: `Component` — 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 } ``` - `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`. Every type and the options it adds are listed in the [Props reference](https://bonguynvan.github.io/bo-grid/docs/reference/#column-types). --- Page: https://bonguynvan.github.io/bo-grid/docs/column-types/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Custom cells > Draw any cell your own way with a Svelte snippet, a component or a plain render function, and add styled tooltips to cells and headers. When a built-in [column type](https://bonguynvan.github.io/bo-grid/docs/column-types/) is not enough, draw the cell yourself. There are three ways, and each changes only how the cell looks: sorting, filtering, copying and export still use the cell's value (and `format`, if set). - A **`cell` snippet** on the grid, for `type: 'custom'` columns, in Svelte. - A **`component`** on a column: a Svelte component per column, reusable across grids. - A **`render(ctx)`** function on a column, which returns a DOM node or an HTML string. It works from React, Vue and plain JavaScript as well. The Move column below is a `cell` snippet; hover the ⓘ in its header, or a cut-off company name, for a tooltip: Example: A cell snippet and tooltips (custom-cells.svelte) ```svelte {#snippet cell({ row }: { row: GridRow; column: ColumnDef; value: unknown })} {@const up = Number(row.change) >= 0} {up ? '▲' : '▼'} {Math.abs(Number(row.change)).toFixed(2)}% {/snippet} ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## A cell snippet Give the column `type: 'custom'` and pass a `cell` snippet to the grid: declared inside ``, it is the `cell` prop, and its parameters get their types from it. It receives `{ row, column, value }`, so one snippet can serve several custom columns by checking `column.key`: ```svelte {#snippet cell({ row })} {@const change = Number(row.change)} = 0}> {change >= 0 ? '▲' : '▼'} {Math.abs(change).toFixed(2)}% {/snippet} ``` ## A component per column Set `component` on any column. The component receives `value`, `row`, `column` and `text` (the value formatted as copy and export see it): ```ts import StatusPill from './StatusPill.svelte'; const columns: ColumnDef[] = [ { type: 'text', key: 'status', header: 'Status', component: StatusPill }, ]; ``` ```svelte {text} ``` The component updates in place as its props change. Under `api.patchRows`, `row` is a fresh view of the patched row on each patch, so read it reactively (in markup or `$derived`) rather than once when the component mounts. A component is usually part of a [registered cell type](https://bonguynvan.github.io/bo-grid/docs/column-types/#registered-cell-types), so every grid that shows, say, a country uses the same cell. The **Team** example below does that for its Country column. ## A render function Snippets and components are Svelte features, so outside Svelte (the React and Vue components, or the `` element) give the column a `render(ctx)` function. `ctx` is `{ value, row, column }`. Return a DOM node, which is appended as it is, or an HTML string: ```js { type: 'custom', key: 'trend', header: 'Trend', render: ({ row }) => { const el = document.createElement('span'); const up = Number(row.change) >= 0; el.textContent = up ? '▲' : '▼'; el.style.color = up ? 'var(--bo-up)' : 'var(--bo-down)'; return el; }, } ``` > An HTML string is inserted as `innerHTML`. If it contains data you do not control, escape it first (`escapeHTML` is exported) or return a node instead. For text-only changes, a `format(value, row)` function is simpler than any of these, and it also changes what copy and export produce. ## Tooltips and truncation Long values end in an ellipsis. Set `tooltip` on a column to show the full text in a styled tooltip on hover: `true` shows the formatted value, and a function builds the text from the value and the row. Return `''` to show no tooltip for a cell. ```ts const columns: ColumnDef[] = [ { type: 'text', key: 'note', header: 'Notes', width: 220, tooltip: true }, { type: 'badge', key: 'status', header: 'Status', tooltip: (value, row) => `${value} · ${row.role}` }, { type: 'text', key: 'bio', header: 'Bio', flex: 1, wrap: true }, ]; ``` `wrap: true` lets a column's text wrap onto more lines instead of truncating; pair it with a taller [row height](https://bonguynvan.github.io/bo-grid/docs/row-height/). ## Header tooltips `headerTooltip` shows the same tooltip on a column's header: what the column means, its units, its caveats. Add `headerInfo: true` for a small ⓘ that shows a tooltip is there: ```ts { type: 'progress', key: 'workload', header: 'Workload', headerTooltip: 'Share of capacity allocated this sprint (0–100%).', headerInfo: true, } ``` ## In the gallery Rank medals and score bars drawn with custom cells: Live example: https://bonguynvan.github.io/bo-grid/examples/leaderboard/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) Rich cell types, a registered country type, tooltips and wrapping: Live example: https://bonguynvan.github.io/bo-grid/examples/team/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference - `cell`: `Snippet<[{ row: GridRow; column: ColumnDef; value: unknown }]>` — Render content for `type: 'custom'` columns. ```svelte {#snippet cell({ row, column })}{#if column.key === 'chart'}{/if}{/snippet} ``` - `render`: `(ctx: CellRenderContext) => string | Node | null | undefined` — JS cell renderer — the framework-agnostic alternative to the `cell` snippet, for React/Vue/vanilla (and the `` 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` — 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 } ``` - `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') } ``` - `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`. - `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`. --- Page: https://bonguynvan.github.io/bo-grid/docs/custom-cells/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Conditional formatting > Paint data bars, icons and colour scales into numeric cells from the values themselves, with no custom cell code. Conditional formatting draws a cue into a numeric cell from its value: a bar behind it, an icon beside it, or a background tint. It answers "which rows stand out?" at a glance, and needs no custom cell. All three work on any numeric column and can be combined. Sort by Volume and watch the bars line up; the icons and the tint follow the Chg % values: Example: Icons, a colour scale and data bars (conditional-formatting.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Data bars `dataBar` paints a horizontal bar behind the value, scaled across the column's range. - The range is computed over the rows in view, unless you give `min` and `max`. `min: 0` gives bars proportional to the value. - When the range spans negative values, bars grow left and right from a zero line. - `color` and `negative` override the default up and down colours. ```ts { type: 'volume', key: 'volume', header: 'Volume', dataBar: { min: 0 } } { type: 'number', key: 'pnl', header: 'P&L', decimals: 0, dataBar: {} } // diverging around zero ``` ## Icons `icons` shows an icon beside the value. Each rule has a threshold `at`, an `icon` and a `tone` (`up`, `down`, `amber`, `info` or `neutral`) for its colour. The rule with the highest `at` that is not above the value wins: ```ts { type: 'percent', key: 'change', header: 'Chg %', icons: [ { at: -Infinity, icon: '▼', tone: 'down' }, { at: -0.25, icon: '●', tone: 'neutral' }, { at: 0.25, icon: '▲', tone: 'up' }, ], } ``` ## Colour scales `colorScale` tints the cell background across the column's range: a soft ramp in the theme's colours. - It is computed over the rows in view, unless you give `min` and `max`. - `mid` makes a three-stop scale that diverges at that value, such as `mid: 0` for gains and losses. - `colors` sets the stops: `[low, high]`, or `[low, mid, high]` with `mid`. ```ts { type: 'number', key: 'pnlPct', header: 'P&L %', decimals: 1, colorScale: { mid: 0 } } ``` The `heatmap` column type is the fixed alternative: a ramp between a `min` and a `max` you set, with the value written on top. ## With live data and server data The cues follow the values, so they update with a live feed and work alongside [cell flashing](https://bonguynvan.github.io/bo-grid/docs/flashing/). Automatic ranges are computed over the rows in memory; with a server [data source](https://bonguynvan.github.io/bo-grid/docs/server-data/), which only holds one window of rows at a time, give explicit `min` and `max`. ## In the gallery A correlation matrix drawn as a heat map, with a pinned label column: Live example: https://bonguynvan.github.io/bo-grid/examples/correlation/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference - `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 } } ``` - `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) } ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/conditional-formatting/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Computed columns > Derive a column's value from the whole row, such as a total, a ratio or a P&L, and sort, filter, total and export it like any other column. A computed column derives its value from the row instead of reading a field. Give the column a `value(row)` function: the result is shown, sorted, filtered, totalled, formatted conditionally, copied and exported exactly like a stored value. Use it for KPIs, ratios and deltas you would otherwise have to precompute and keep in sync. Value and P&L below exist on no row; sort by either, and see the footer total them: Example: Value and P&L derived per row (computed-columns.svelte) ```svelte ``` ## Defining one ```ts const columns: ColumnDef[] = [ { type: 'number', key: 'qty', header: 'Qty', decimals: 0 }, { type: 'price', key: 'last', header: 'Last' }, // No `value` field on the rows: derived, and still sortable and exportable. { type: 'number', key: 'value', header: 'Value', decimals: 0, groupAgg: 'sum', value: (r) => Number(r.qty) * Number(r.last) }, ]; ``` - `key` still names the column: sorting, filtering, hiding and saved layouts refer to it. It does not have to be a real field on the row. - The column's `type` formats the result, so a computed price uses `type: 'price'`. - `groupAgg` totals it on [group rows](https://bonguynvan.github.io/bo-grid/docs/grouping/) and in the grid's `footer`. ## Rules - **Keep it cheap and pure.** `value()` runs while sorting and filtering, not only when a cell draws. Read the row and return a value; don't fetch or mutate anything. - **It is read-only.** There is no field to write an edit back to, so computed columns are not editable. - **In memory only.** With a server [data source](https://bonguynvan.github.io/bo-grid/docs/server-data/), the server computes its own derived fields. ## Reference - `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 } ``` - `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' } ``` - `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') } ``` - `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. --- Page: https://bonguynvan.github.io/bo-grid/docs/computed-columns/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Column sizing > Set fixed and flexible widths, let users resize columns, scale every column with the grid, and widen columns whose values outgrow them. Every column has a fixed `width` or a `flex` weight, and users can drag a header's edge to change it. Two switches change how columns respond to the grid's own width: `fitColumns` scales every column with it, and a column's `autoWidth` widens it when its values no longer fit. Drag a header's right edge to resize: only that column changes. Double-click the edge to reset it. Toggle `fitColumns`, then resize the browser window to see the difference: Example: Resizing and fitColumns (column-sizing.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Fixed and flexible widths - **`width`** is a fixed width in pixels. - **`flex`** is a grow weight: flex columns share the space that fixed columns leave, in proportion to their weights. `flex` wins over `width`. By default, only flex columns take up a change in the grid's width; fixed columns stay as they are. ## Resizing Drag the grip on a header's right edge to resize a column. Only that column changes: every other column keeps the width it has on screen, flex columns included, and the columns after it move along. When the columns grow wider than the grid, it scrolls sideways; when they shrink, the space is left at the right. - Double-click a grip to give that column back its own width. A flex column then fills the space the others leave again. - **Reset widths**, in the column menu (`columnMenu`), gives every column back its own width. - Autosize, from the column menu or `api.autosizeColumns`, changes columns the same way: the others keep their widths. - `minWidth` and `maxWidth` bound the drag. - Resizing is on by default. Turn it off for the grid with `resizable={false}`, or for one column with `resizable: false`, for example a narrow action column. - `onColumnResize(key, width)` reports each resize; `persistKey` remembers widths across reloads. ```svelte save(key, width)} height={480} /> ``` ## Scaling every column: fitColumns With `fitColumns`, every column scales with the grid. Each gets a share of the width in proportion to its `width`, bounded by its `minWidth` and `maxWidth`. A column with no `minWidth` shrinks to 64 px at most (or keeps its own width, if that is smaller). The grid scrolls sideways only when those floors no longer fit. ```svelte ``` `fitColumns` fits until the user sets a width. After a drag or an autosize, the columns keep the widths they were given, as they do without it, until **Reset widths** brings the fit back. ## Growing with the values: autoWidth Values that grow, such as a price gaining a digit or a running volume total, can outgrow their column and end in "…". Set `autoWidth: true` on such a column and it widens to fit the values on screen. ```ts { type: 'volume', key: 'totalVol', header: 'Total vol', width: 76, autoWidth: true } ``` - It is checked at most every 500 ms, and values are measured on a canvas, so it never forces a layout on a live board. - It only grows, so values flickering wider and narrower don't make the column jump. `maxWidth` caps it. - A width the user dragged or autosized takes over from it. ## Autosizing from code `api.autosizeColumns(keys?)` fits columns to their content, as the column menu's Autosize item does; with no keys it fits every resizable column. See [Grid API](https://bonguynvan.github.io/bo-grid/docs/grid-api/). ## Reference - `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. - `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`. - `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. - `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. - `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`. - `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. - `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 save(`width:${key}`, width)} /> ``` - `persistKey`: `string` (default `undefined`) — Remember the user's layout (column order, widths, hidden and pinned columns, pinned rows) in localStorage under this key. - `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']); ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/column-sizing/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Moving & hiding > Let users drag columns into a new order and hide or show them from a column menu or a Columns panel, or drive visibility from your own UI. Users can drag a column header to move the column, and hide or show columns from a per-column menu or a Columns panel. Your app can also hide columns itself through `hiddenColumns`, and the grid reports every change so you can save it. Drag a header sideways to move its column. Open a header's ⋮ menu to sort, pin, autosize or hide it, and use the Columns button to bring hidden columns back: Example: Moving and hiding columns (column-moving.svelte) ```svelte

Order: {order.join(' · ')}
Hidden: {hidden.length ? hidden.join(', ') : 'none'}

(order = keys)} onColumnVisibilityChange={(keys) => (hidden = keys)} /> ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Moving columns Drag any column header to a new position. `onColumnReorder` receives the new order of column keys, and `persistKey` remembers it across reloads (in `localStorage`): ```svelte (columnOrder = keys)} height={480} /> ``` The `columns` array you pass keeps its own order; the user's order is layered on top. `api.getState()` and `api.applyState()` capture and restore it along with widths, pins, sorts and filters (see [Grid API](https://bonguynvan.github.io/bo-grid/docs/grid-api/)). ## The column menu `columnMenu` adds a ⋮ button to each header, also opened with Alt+↓ on the focused column. It offers: - **Sort** ascending or descending. - **Pin** left or right, or unpin (see [Pinned columns](https://bonguynvan.github.io/bo-grid/docs/pinned-columns/)). - **Autosize**: fit the column to its content. - **Hide column**. - **Filter**, when the grid also has `filterMenu` (see [Sorting & filtering](https://bonguynvan.github.io/bo-grid/docs/sorting-filtering/)). ## The Columns panel `columnsPanel` adds a Columns button that opens a checklist of every column, to hide and show them. It is the place to bring back a column hidden from the menu. ```svelte ``` ## Hiding columns from your app `hiddenColumns` takes the keys of columns to hide. It is controlled: build your own column picker and drive the prop. Columns the user hides at runtime are added to it, and `onColumnVisibilityChange` reports the full set of hidden keys whenever it changes: ```svelte (hidden = keys)} height={480} /> ``` Runtime hides and pins are remembered under `persistKey` too. ## Reference - `onColumnReorder`: `(keys: string[]) => void` — Called with the new column-key order after a header drag-reorder. ```svelte save('order', keys)} /> ``` - `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. - `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 ``` - `onColumnVisibilityChange`: `(hidden: string[]) => void` — Called with all currently-hidden column keys whenever the runtime set changes (column menu hide/show). ```svelte save('hidden', hidden)} /> ``` - `persistKey`: `string` (default `undefined`) — Remember the user's layout (column order, widths, hidden and pinned columns, pinned rows) in localStorage under this key. - `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)); ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/column-moving/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Pinned columns > Keep key columns, such as a symbol or a total, in view on the left or right edge while the rest of the grid scrolls sideways. A pinned column stays in view while the other columns scroll sideways. Pin the column that identifies a row (a symbol, a name) to the left, and a summary column (a total, an action) to the right. Scroll the grid sideways: Symbol stays on the left and Volume on the right while the returns in between scroll under them. Example: Columns pinned left and right (pinned-columns.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Pinning a column Set `pinned` on the column: `true` or `'left'` pins it to the left edge, `'right'` to the right edge. ```ts const columns: ColumnDef[] = [ { type: 'text', key: 'symbol', header: 'Symbol', width: 90, pinned: 'left' }, { type: 'price', key: 'price', header: 'Price', width: 100 }, // …more fixed-width columns scroll under the pinned ones… { type: 'volume', key: 'volume', header: 'Volume', width: 110, pinned: 'right' }, ]; ``` - Pinning is opt-in. With no pinned columns the grid fits its columns to its width and never scrolls sideways unless they don't fit. - Give the scrolling columns fixed widths, so there is something to scroll. - Users can pin and unpin from the [column menu](https://bonguynvan.github.io/bo-grid/docs/column-moving/#the-column-menu) (`columnMenu`). Their pins are part of `api.getState()` and are remembered under `persistKey`. ## With wide grids For grids with dozens or hundreds of columns, `virtualizeColumns` renders only the columns in the horizontal window. Pinned columns always render. See [Wide grids](https://bonguynvan.github.io/bo-grid/docs/wide-grids/). ## Pinning rows To keep rows in view instead, such as a watchlist's favourites, see `pinnedRows` and `rowPinning` in [Cell selection & clipboard](https://bonguynvan.github.io/bo-grid/docs/selection/). ## Reference - `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. - `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. - `columnMenu`: `boolean` (default `false`) — Enable a per-column header menu (a ⋮ trigger) with sort, hide and (with `filterMenu`) filter actions. - `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. - `persistKey`: `string` (default `undefined`) — Remember the user's layout (column order, widths, hidden and pinned columns, pinned rows) in localStorage under this key. --- Page: https://bonguynvan.github.io/bo-grid/docs/pinned-columns/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Header groups > Put a spanning parent header over related columns, such as Bid, Ask and Match on a price board. A header group is a second header row that spans related columns, so a reader sees which columns belong together: Bid price and volume, Ask price and volume, the match price and change. Columns in a group keep their own headers, sorting and resizing. Bid, Ask and Match each span the columns under them; sort or resize any column as usual: Example: Bid, Ask and Match groups (header-groups.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Grouping columns Give columns a `group` label. Consecutive columns with the same label render under one spanning header: ```ts const columns: ColumnDef[] = [ { type: 'text', key: 'symbol', header: 'Symbol', width: 90 }, { type: 'price', key: 'bid', header: 'Price', width: 90, group: 'Bid' }, { type: 'volume', key: 'bidVol', header: 'Vol', width: 90, group: 'Bid' }, { type: 'price', key: 'ask', header: 'Price', width: 90, group: 'Ask' }, { type: 'volume', key: 'askVol', header: 'Vol', width: 90, group: 'Ask' }, ]; ``` - Only consecutive columns join a group. The same label further along starts a new span, so if users can move columns, a group can split in two. - Columns without a `group` have an empty cell above them. - A group keeps the width of its columns as they resize, flex columns included. ## Reference - `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). --- Page: https://bonguynvan.github.io/bo-grid/docs/header-groups/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Row height & grid size > Set the row height for the density you want, give rows their own heights, and size the grid in pixels or to fill its container. Rows are 36 px tall by default. `rowHeight` sets another height for every row, or a height per row. The grid's own size comes from `height`: a number of pixels for the scrolling area, or a CSS length that sizes the whole grid to fit its container. Switch between the three densities; the grid keeps scrolling smoothly at each: Example: Compact, default and roomy rows (row-height.svelte) ```svelte
{#each Object.entries(densities) as [label, h] (label)} {/each}
``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## One height for every row ```svelte ``` For a fully compact or roomy look, pair `rowHeight` with the theme's `cellPad` (horizontal padding) and `fontSize`; see [Theming](https://bonguynvan.github.io/bo-grid/docs/theming/). ## A height per row Pass a function of the row and its index, for rows that need more room, such as a wrapped note: ```svelte (row.note ? 72 : 36)} height={480} /> ``` Variable heights are virtualized with a prefix sum and a binary search, so scrolling stays fast with many rows. They need the rows in memory: with a server [data source](https://bonguynvan.github.io/bo-grid/docs/server-data/), rows not yet loaded have no known height, so source mode uses one height. Text only wraps in columns that ask for it with `wrap: true` (see [Custom cells](https://bonguynvan.github.io/bo-grid/docs/custom-cells/#tooltips-and-truncation)). ## Sizing the grid `height` takes a number or a CSS length: - **A number** is the height of the scrolling area in pixels. The grid's full height is that plus its header, and any toolbar, pager or footer. - **A CSS string** (`'100%'`, `'80vh'`, `'480px'`) sizes the whole grid, and the scrolling area fits the space left after its header and other parts. Give the grid a parent with a size, such as a flex or grid cell, for `'100%'` to fill. ```svelte ``` ## Reference - `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 (row.note ? 56 : 36)} /> ``` - `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 ``` - `detailHeight`: `number` (default `160`) — Height (px) of the expanded detail panel. - `wrap`: `boolean` (default `false`) — Let long content wrap to multiple lines instead of truncating with an ellipsis. Pair with a taller `rowHeight`. --- Page: https://bonguynvan.github.io/bo-grid/docs/row-height/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Row selection > Add a checkbox column for selecting whole rows, keyed by row id so the selection survives sorting, filtering and live updates. Row selection adds a leading checkbox column for picking whole rows, for example to act on several orders at once. The selection is a set of row ids, not positions, so it survives sorting, filtering and a live feed reordering rows. It is separate from [cell selection](https://bonguynvan.github.io/bo-grid/docs/selection/), the range a user drags over cells. Tick a few rows, then sort a column: the same rows stay ticked. The header checkbox selects or clears every row that matches the current filter. Example: Checkbox row selection (row-selection.svelte) ```svelte

Selected ids: {selected.length ? selected.join(', ') : 'none'}

(selected = ids)} height={260} /> ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Turning it on ```svelte (selected = ids)} height={480} /> ``` - Click a checkbox, or press Space on the focused row. - The header checkbox selects or clears all rows that pass the current filters. - `onRowSelectionChange` receives the selected ids whenever the set changes. - `api.getSelectedRows()` returns the ticked rows themselves, in view order. ## Row ids Selection is keyed by `row.id`. If your rows are identified by another field, such as a UUID or a composite key, pass `getRowId`: ```svelte String(r.uuid)} height={480} /> ``` ## One active row For a list-and-detail layout, where one row is "open", use `selectedRowId` instead. It highlights a single row, is fully controlled by your app, and is independent of the checkboxes. Pair it with `onRowClick`: ```svelte (openId = r.id)} height={480} /> ``` ## With server data With a server [data source](https://bonguynvan.github.io/bo-grid/docs/server-data/), the checkboxes work on loaded rows. The header checkbox is disabled, because ids of rows that are not loaded cannot be listed. ## Reference - `rowSelection`: `boolean` (default `false`) — Show a leading checkbox column for whole-row selection (keyed by row id, stable across sort/filter). - `onRowSelectionChange`: `(selectedIds: Array) => void` — Called with the selected row ids whenever the row-selection set changes. ```svelte (selected = ids)} /> ``` - `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 `${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 (selectedRowId = row.id)} /> ``` - `getSelectedRows(): GridRow[]` — Ticked rows (needs `rowSelection`), in view order. ```ts const ids = api.getSelectedRows().map((row) => row.id); ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/row-selection/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Row numbers > Add a spreadsheet-style row-number column that follows sorting and filtering and is announced by screen readers. `rowNumbers` adds a leading column that numbers the rows 1, 2, 3…, as in a spreadsheet. The numbers are positions in the current view, not ids, so they follow sorting and filtering. Sort any column: the rows move and the numbers stay in order. Example: Row numbers (row-numbers.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Turning it on ```svelte ``` - Data rows are numbered; group header rows get no number. - The column stays in place when the grid scrolls sideways, and it is sized to the widest number. - Its cells are row headers, so a screen reader announces the row's number with each cell. The column's header reads "Row", the `rowNumber` entry in [`labels`](https://bonguynvan.github.io/bo-grid/docs/localization/). ## Reference - `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. - `labels`: `Partial` (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 ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/row-numbers/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Row styles > Style rows and cells from their data with classes, react to a row being opened, and give rows a right-click menu. Rows and cells take classes chosen from their data: `rowClass` for a whole row, a column's `cellClass` for its cells. The same page covers what a row does when used: `onRowClick` when it is opened, and `rowMenu` for its right-click menu. Falling stocks are dimmed by `rowClass`, and big gains are bold through `cellClass`. Click a row, or right-click it for its menu: Example: Row classes, row click and a row menu (row-styles.svelte) ```svelte

Click a row or press Enter on it; right-click for its menu. Last opened: {last || 'none'}

(last = String(r.symbol))} height={260} /> ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Classes from the data `rowClass(row)` returns a class for a data row, or nothing. Rows render inside the grid, so target the class with `:global(...)`: ```svelte (Number(r.change) < 0 ? 'falling' : undefined)} height={480} /> ``` For one column, `cellClass` is a class string, or a function of the cell's value and row; `headerClass` styles the column's header: ```ts { type: 'number', key: 'pnl', header: 'P&L', cellClass: (v) => (Number(v) < 0 ? 'loss' : 'gain'), headerClass: 'num-head' } ``` ```css :global(.bo-grid .c.loss) { color: var(--bo-down); } ``` For colour cues that scale with the value, such as bars and heat tints, see [Conditional formatting](https://bonguynvan.github.io/bo-grid/docs/conditional-formatting/). ## Opening a row `onRowClick(row, event)` fires when a row is clicked, or when Enter is pressed on its focused cell. Clicks in an edit input or a checkbox don't count. Use it to open a detail view or navigate; to keep the opened row highlighted, pass its id as `selectedRowId` (see [Row selection](https://bonguynvan.github.io/bo-grid/docs/row-selection/#one-active-row)). ## A row menu `rowMenu(row)` returns the items of a right-click menu for that row; return an empty array for no menu. Each item has a `label` and an `onSelect`, and the menu closes after an item runs, on a click outside, or on Esc. From the keyboard, the ContextMenu key or Shift+F10 opens it at the focused cell. ```svelte [{ label: 'Delete', onSelect: () => remove(r.id) }]} height={480} /> ``` ## Reference - `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 (row.status === 'Rejected' ? 'rejected' : undefined)} /> ``` - `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 (selectedRowId = row.id)} /> ``` - `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. - `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 [{ label: 'Cancel order', onSelect: () => cancel(row) }, { label: 'Copy id', onSelect: () => copy(String(row.id)) }]} /> ``` - `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`. --- Page: https://bonguynvan.github.io/bo-grid/docs/row-styles/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Row reordering > Let users drag rows into their own order with a handle, for task lists, watchlists and priorities. Row reordering adds a drag handle to the first column, so users can put rows in their own order: a task list, a watchlist, a priority queue. The grid reports where a row was dropped, and your app moves it in its own array. Grab the ⠿ handle at the start of a row and drop it on another row: Example: Drag to reorder tasks (row-reordering.svelte) ```svelte ``` ## Turning it on Pass `onRowReorder`. It receives the row's old and new positions in the rows on screen; move the row in your array and assign a new array: ```svelte ``` ## When it applies The positions are indices into the rows on screen, so they match your array only when the view shows it as it is: - **Unsorted and unfiltered.** Make the columns `sortable: false`, as in the example, and don't filter a reorderable list. A sorted view would put the dropped row back in sort order anyway. - **Flat, in memory, one page.** The handle does not appear with grouping (`groupBy`), tree data, a server data source, or paging (`pageSize`). ## In the gallery Live example: https://bonguynvan.github.io/bo-grid/examples/tasks/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference - `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 { const next = [...rows]; next.splice(to, 0, ...next.splice(from, 1)); rows = next; }} /> ``` - `sortable`: `boolean` (default `true`) — Set false to disable header-click sorting on this column. --- Page: https://bonguynvan.github.io/bo-grid/docs/row-reordering/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Pinned rows > Keep rows above the scroll, always in view, whether they are rows of your own or rows readers pin from the board. A pinned row stays above the scroll while the rest of the grid moves under it. Use it for a row that should never leave the screen: a benchmark, a summary, your position, or the symbols a reader is watching on a long board. The INDEX row is passed in `pinnedRows`; pin others yourself with a right-click, or with the button, which calls the grid API. Example: Pinned rows (pinned-rows.svelte) ```svelte

Right-click a row to pin it. Pinned: {pinned.length ? pinned.join(', ') : 'none'}

(pinned = ids)} onReady={(a) => (api = a)} /> ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Rows of your own `pinnedRows` takes rows that are not in `rows`: they sit above the scroll, use the grid's columns and `rowClass`, and are display-only. Their ids must not clash with ids in `rows`. ```svelte ``` ## Rows readers pin `rowPinning` lets readers pin rows themselves. Right-clicking a row offers **Pin to top**, and **Unpin** on a pinned row, ahead of any `rowMenu` items. - A pinned row shows above the scroll and also stays in its place in the body. It keeps updating, `api.patchRows` included. - Pins are remembered with `persistKey`, and carried by `getState()` and `applyState()`. - `onRowPinChange(ids)` reports every change. - Pinning works on rows in `rows`; with a server `source`, pin rows of your own with `pinnedRows`. ```svelte save(ids)} /> ``` ## From code `api.pinRow(id)` pins a row and `api.pinRow(id, false)` unpins it; it returns `false` when no row has that id. `api.getPinnedRowIds()` returns the pinned ids in the order they were pinned. ```ts api.pinRow(2); // pin the row with id 2 api.pinRow(2, false); // unpin it api.getPinnedRowIds(); // [] again ``` ## Reference - `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 ``` - `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()`. - `onRowPinChange`: `(ids: (string | number)[]) => void` — Called with the pinned row ids (in pin order) whenever they change. ```svelte save('pinned', ids)} /> ``` - `persistKey`: `string` (default `undefined`) — Remember the user's layout (column order, widths, hidden and pinned columns, pinned rows) in localStorage under this key. - `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); ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/pinned-rows/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Merged cells > Merge repeated values down a column, or let a cell span several columns, without changing the data underneath. Merged cells remove repetition that hides structure. On a blotter, the account, order and symbol repeat down every fill; merged, each shows once, and the order boundaries stand out. A cell can also span columns to the right, for a note that needs the room. Merging changes only what is drawn: every row keeps its own values. Account, order and symbol merge down their runs; the note row spans the Qty and Price columns: Example: Merging down and across (merged-cells.svelte) ```svelte ``` ## Down a column: spanRows `spanRows: true` merges a cell over adjacent rows with the same value. Blank values never merge. ```ts const columns: ColumnDef[] = [ { type: 'text', key: 'account', header: 'Account', width: 100, spanRows: true }, { type: 'text', key: 'order', header: 'Order', width: 96, spanRows: true }, { type: 'text', key: 'symbol', header: 'Symbol', width: 84, spanRows: true }, { type: 'text', key: 'time', header: 'Time', width: 90 }, { type: 'price', key: 'price', header: 'Price', flex: 1 }, ]; ``` To decide per pair of adjacent rows instead, pass a function, for example to merge dates on the same calendar day: ```ts { type: 'date', key: 'filledAt', header: 'Day', spanRows: (a, b) => sameDay(a.filledAt, b.filledAt) } ``` - **Hierarchical, in display order.** A run also breaks wherever a merging column to its left breaks, so a symbol never merges across two accounts. Moving columns changes the hierarchy. - **Only adjacent rows merge**, so merging follows the current sort: sort by the merged column for the largest merges. Group headers, tree placeholders and an expanded detail row break every run. - **A drawing rule, not a data change.** Sorting, filtering, copying, CSV and Excel export, and `toHTMLTable` all see one value per row. - A merged cell is not editable. Selecting any row in a run highlights the cell. - Runs are recomputed when the view changes, like sorting and filtering, not on every realtime tick. ## Across columns: colSpan `colSpan(row)` returns how many columns a cell covers in that row; the covered cells are not drawn. A note row, for example, can cover the fill columns: ```ts { type: 'text', key: 'qtyText', header: 'Qty', value: (r) => r.note ?? String(r.qty), colSpan: (r) => (r.note ? 2 : 1), } ``` - The leftmost claim wins when spans overlap. - A span stops at the last column and at the edge between pinned and scrolling columns. - A covered cell breaks the `spanRows` run of the column it covers, but not of a column to its left. On one column with both, a run continues only while the rows below span the same width. - `colSpan` is ignored when `virtualizeColumns` is on. ## Both are in memory only Merging needs neighbouring rows, so it applies to rows in memory. With a server [data source](https://bonguynvan.github.io/bo-grid/docs/server-data/), which holds one window of rows at a time, cells don't merge. The planning is a pure function, `buildMergePlan(...)` with `colSpanRow(...)`, exported for anything that draws its own view of the same rows. ## In the gallery A trade blotter with merged account, order and symbol runs, and note rows across the fill columns: Live example: https://bonguynvan.github.io/bo-grid/examples/blotter/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference - `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) } ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/merged-cells/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Master-detail > Expand a row into a detail panel underneath it, drawn by a snippet with anything inside, including another grid. Master-detail lets a row expand into a panel underneath it, for information that does not fit in columns: an order's fills, a customer's contacts, a chart. The grid adds a leading expand toggle to each row and draws the panel from a snippet you pass. Click the toggle at the start of a row to open its panel; open several, then scroll: Example: Rows that expand into a detail panel (master-detail.svelte) ```svelte {#snippet detail({ row }: { row: GridRow })}
{row.name} · {row.sector}
Last
{Number(row.price).toFixed(2)}
Change
{Number(row.change).toFixed(2)}%
Volume
{Number(row.volume).toLocaleString()}
{/snippet} ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Adding a detail panel Pass a `detail` snippet, declared inside `` so it is typed from the prop. It receives `{ row }`, and its content can be anything, including another `Grid`: ```svelte {#snippet detail({ row })}
{row.name} · {row.sector}
{/snippet}
``` - `detailHeight` sets the panel's height in pixels (default 160). - Expanded panels are virtualized with the rows, so many open panels stay cheap to scroll. - Master-detail needs the rows in memory; it is not available with a server [data source](https://bonguynvan.github.io/bo-grid/docs/server-data/). Rows keep one height, so give `rowHeight` a number rather than a function. - A detail row breaks [merged-cell](https://bonguynvan.github.io/bo-grid/docs/merged-cells/) runs. To highlight one "open" row without expanding it, for a list beside a separate detail view, use `selectedRowId` and `onRowClick` instead (see [Row selection](https://bonguynvan.github.io/bo-grid/docs/row-selection/#one-active-row)). ## Outside Svelte `detail` is a Svelte snippet, so the React and Vue components and the `` element cannot use it. See [React, Vue & more](https://bonguynvan.github.io/bo-grid/docs/frameworks/#custom-cells-outside-svelte). ## Reference - `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 {#snippet detail({ row })}

{row.sym}: {row.note}

{/snippet}
``` - `detailHeight`: `number` (default `160`) — Height (px) of the expanded detail panel. --- Page: https://bonguynvan.github.io/bo-grid/docs/master-detail/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Pagination > Show rows a page at a time with a pager and an optional rows-per-page choice, instead of one long scroll. The grid scrolls through any number of rows without paging, since only the rows on screen are drawn. When pages suit the task better, such as a report someone works through or a page number to refer to, `pageSize` shows rows a page at a time with a pager. Move through the 120 rows with the pager, or pick another page size; sorting and filtering apply to all rows, not just the page: Example: A paged grid (pagination.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Turning it on ```svelte ``` - `pageSize` above 0 shows a pager with first, previous, next and last buttons. Rows still virtualize within a page. - `pageSizeOptions` adds a rows-per-page choice to the pager. Picking one goes back to the first page, and `onPageSizeChange` reports the new size. - Pages apply to rows in memory; a server [data source](https://bonguynvan.github.io/bo-grid/docs/server-data/) already loads rows a window at a time as the user scrolls. ## Controlling the page The current page is the grid's own by default. To drive it, for example from the URL, pass `page` (0-based) and update it from `onPageChange`: ```svelte (page = p)} height={480} /> ``` The page size works the same way: it changes on its own from the dropdown, unless you also drive `pageSize` and update it from `onPageSizeChange`. ## Reference - `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 ``` - `onPageSizeChange`: `(size: number) => void` — Called with the new page size when the pager's size dropdown changes. ```svelte (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 (page = p)} /> ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/pagination/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Sorting & filtering > Sort by one or more columns, search across every column, and filter each column with a menu matched to its type. Every column sorts from its header, and the grid has three ways to filter: a quick search across all columns, a row of per-column inputs, and a filter menu on each header. They combine: a row is shown when it passes all of them. Click a header to sort, Shift-click another to add it to the sort, open a header's funnel to filter it (Sector has a checkbox list), or type in the search box. Example: Sort and filter (sorting-filtering.svelte) ```svelte (sort = s)} /> ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Sorting Click a column header to sort it ascending, again for descending, and a third time to clear. **Shift-click** more headers to sort by several columns; each sorted header shows its place in the order. - Numeric columns sort by number, dates by time, text with a locale-aware collator. - Give a column `compare(a, b)` for a custom order (a status workflow, a rating scale). - Set `sortable: false` to stop a column sorting. Sparkline columns never sort. ### A sort you own Sorting is uncontrolled by default. To set an initial sort, persist it or sync it to the URL, pass `sort` and handle `onSortChange`: ```svelte (sort = s)} /> ``` ### Sorting live data A sort is a snapshot: when values change in place (a price feed), rows keep their position, so a live board never reshuffles under the reader. Call `api.refresh()` when the order should catch up. With `api.patchRows`, `liveSort` keeps it current instead: rows whose sorted or filtered field changed are merged back into place at most every 500 ms (or the interval you give). See [Live updates](https://bonguynvan.github.io/bo-grid/docs/realtime/). ## Quick search Pass a `filter` string to keep only rows where some column's value contains it, ignoring case. Bind it to your own search input, or set `quickFilter` for a built-in search box above the grid (with a server source, its text reaches `getRows` as `quickFilter`): ```svelte ``` ## Filter row `filterRow` adds a row of inputs under the header, one per column. A row must match every input that has text. In-memory data only. ```svelte ``` ## Filter menu `filterMenu` adds a funnel to each header. Its menu matches the column type: | Column | Operators | | --- | --- | | Text | contains, does not contain, equals, starts with, ends with | | Number | `=`, `≠`, `<`, `≤`, `>`, `≥`, between | | Date | before, after, on, between (whole days, in the viewer's time zone) | | Set (`filter: 'set'`) | a searchable checkbox list of the column's distinct values | Set `filter: false` on a column to leave it without a funnel. The menu's code loads on first open, so it costs nothing until it is used. ```ts const columns: ColumnDef[] = [ { type: 'text', key: 'sector', header: 'Sector', filter: 'set' }, { type: 'price', key: 'price', header: 'Price' }, // number operators { type: 'text', key: 'note', header: 'Note', filter: false }, // no funnel ]; ``` For a filter kind of your own (a band, a range rule), see [Custom filters](https://bonguynvan.github.io/bo-grid/docs/custom-filters/). ### Filters you own Like sorting, filtering is uncontrolled by default. Pass `columnFilters` (a map from column key to filter) and handle `onFilterChange` to set initial filters, save them or put them in the URL: ```svelte (filters = f)} /> ``` A filter is a plain, JSON-safe object: `{ kind: 'text', op, q }`, `{ kind: 'number', op, a, b? }`, `{ kind: 'date', op, a, b? }` (epoch ms) or `{ kind: 'set', excluded }`. ## With a server source With a `source`, the grid sends the sort, the `filter` text and the column filters to your `getRows` and shows what it returns. Text, number and date menus work as usual; a set filter needs the distinct values, so it is for in-memory data. See [Server-side data](https://bonguynvan.github.io/bo-grid/docs/server-data/). ## Reference - `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 (sort = next)} /> ``` - `onSortChange`: `(sort: SortState[]) => void` — Called with the new sort order whenever a header is clicked. ```svelte save('sort', sort)} /> ``` - `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. - `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`. - `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. - `columnFilters`: `Record` (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 ``` - `onFilterChange`: `(filters: Record) => void` — Called with the full column-filter map whenever a header filter changes. ```svelte (columnFilters = next)} /> ``` - `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 ``` - `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)] } ``` - `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 ). `'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' } ``` - `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); ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/sorting-filtering/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Custom filters > Register a filter kind of your own, with its own editor in the column's filter menu, when text, number, date and set filters do not fit. The built-in filter menu covers text, numbers, dates and sets. When a column needs another rule (a band of values, a lot-size check, "within N km"), register a filter kind with `filterTypes` and point the column at it with `filter: 'name'`. The menu keeps its frame (title, **Clear**, **Apply**, Enter to apply), and your component edits the middle. Open the funnel on **Chg %**: it shows a registered `move` filter that keeps rows by how far they moved. Example: A registered filter kind (custom-filters.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` custom-filters-move.ts: ```ts import type { CustomFilter, FilterTypeDef } from 'bo-grid'; import MoveEditor from './custom-filters-editor.svelte'; export type Move = 'down' | 'flat' | 'up'; /** A registered filter: a plain object with a `kind`, so it saves and restores like any other. */ export interface MoveFilter extends CustomFilter { kind: 'move'; moves: Move[]; } export const MOVES: { id: Move; label: string }[] = [ { id: 'down', label: 'Down more than 0.5%' }, { id: 'flat', label: 'Within ±0.5%' }, { id: 'up', label: 'Up more than 0.5%' }, ]; const moveOf = (pct: number): Move => (pct < -0.5 ? 'down' : pct > 0.5 ? 'up' : 'flat'); export const move: FilterTypeDef = { component: MoveEditor, isActive: (f) => f.moves.length > 0, test: (value, f) => f.moves.includes(moveOf(Number(value))), }; ``` ## Define the filter kind A filter kind is a test, an optional "is it narrowing anything" check, and an editor component: ```ts import type { CustomFilter, FilterTypeDef } from 'bo-grid'; import MoveEditor from './MoveEditor.svelte'; type Move = 'down' | 'flat' | 'up'; export interface MoveFilter extends CustomFilter { kind: 'move'; moves: Move[]; } const moveOf = (pct: number): Move => (pct < -0.5 ? 'down' : pct > 0.5 ? 'up' : 'flat'); export const move: FilterTypeDef = { component: MoveEditor, isActive: (f) => f.moves.length > 0, // optional; by default always active test: (value, f) => f.moves.includes(moveOf(Number(value))), }; ``` `test(value, filter, row)` decides one cell and runs only while the filter is active. ## Register it Pass the kinds to the grid by name, and select one on a column: ```svelte ``` ## Write the editor The editor gets the draft as `filter` (the active filter when the menu opened, or `null`) and calls `onChange(next)` as the user edits. **Apply** commits the draft when it is active, and **Clear** removes the filter. Pass `null` for "no filter". ```svelte ``` The editor also receives `column`, `labels` and, when the kind sets `needsValues: true`, the column's distinct values as `values` (as the set filter uses them). ## Saving and server sources A custom filter is a plain object with a `kind`, so it goes through `columnFilters`, `onFilterChange` and `getState()` unchanged, and reaches a `RowSource` in `params.columnFilters`. To apply it in `createArraySource`, pass the same kinds: ```ts const source = createArraySource(rows, { filterTypes: { move } }); ``` A filter whose kind is not registered is treated as inactive: it filters nothing, rather than emptying the grid. ## Reference - `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 Number(v) >= f.min && Number(v) <= f.max } }} /> ``` - `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. - `columnFilters`: `Record` (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 ``` - `onFilterChange`: `(filters: Record) => void` — Called with the full column-filter map whenever a header filter changes. ```svelte (columnFilters = next)} /> ``` - `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 ). `'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' } ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/custom-filters/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Find in grid > A find bar on Ctrl/⌘+F that searches the cells' displayed text and steps through every match. `findBar` turns on Ctrl/⌘+F inside the grid: a small bar opens over the top-right corner and finds text in the cells, as a browser finds text in a page, including rows that are scrolled out of view. Unlike a filter, it hides nothing. Press the button, or click a cell and press Ctrl/⌘+F, then Enter to step through the matches. Example: Find bar (find.svelte) ```svelte

or click a cell and press Ctrl/⌘+F

(api = a)} /> ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Turning it on ```svelte ``` It is off by default, because while the grid has focus it takes Ctrl/⌘+F from the browser. ## How it searches - It matches what the cells show (their formatted text), ignoring case, and counts the matches ("3 of 12"). - Enter and Shift+Enter step through them; each match is focused and scrolled into view. - Escape closes the bar and returns focus to the grid. - It searches in-memory rows (not a `source`), up to 10,000 matches. Matches are found once per query and view, not on every tick, so on a live board the values can move after the bar has found them. ## Opening it from code `api.openFind(query?)` opens the bar from your own button or shortcut, and searches for `query` at once when you give one: ```svelte (api = a)} /> ``` To narrow the rows instead of finding them, use the quick search in [Sorting & filtering](https://bonguynvan.github.io/bo-grid/docs/sorting-filtering/). ## Reference - `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. - `openFind(query?: string): void` — Open the find bar (`findBar`), optionally searching for `query` at once. ```ts api.openFind('VNM'); ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/find/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Inline editing > Edit cells in place with an editor matched to the column type, validate the value, and write it back to your rows. Mark a column `editable: true` and its cells can be edited in place. The grid is controlled: it never writes to your data itself, but reports each change through `onCellEdit`, where you update the row (or send the change to a server first). Double-click a cell, press Enter on it, or select it and start typing. Status opens a dropdown, and Qty refuses a negative number. Example: Editable cells (editing.svelte) ```svelte

{last}

{ e.row[e.column.key] = e.value; last = `${e.row.symbol} · ${e.column.header} → ${String(e.value)}`; }} /> ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Making a column editable ```svelte (e.row[e.column.key] = e.value)} /> ``` Keep the rows in `$state` so the cell shows the new value once you write it. The event carries the `row`, the `column` and the new `value`. ## Opening and closing the editor - Double-click a cell, or press Enter or F2 on the focused cell. - Start typing on a focused cell: the editor opens with that character, as in a spreadsheet. (Date and dropdown editors open with Enter or a double-click.) - Enter or leaving the cell saves; Tab saves and moves to the next cell (on to the next row at the end of one); Escape cancels. The grid keeps the keyboard afterwards. - Picking an option in a dropdown with the pointer saves at once; with the arrow keys, Enter saves. - What you type is yours until you save: a live update to the cell does not overwrite it. ## The editor for each type The editor matches the column: | Column | Editor | `e.value` | | --- | --- | --- | | Number types (`number`, `price`, `percent`, `volume`, `currency`, `heatmap`) | a text input with a numeric keypad, so `-` and `.` type as expected | a number; a blank, or text that is not a number, is refused | | `date` | the browser's date picker | epoch milliseconds | | Any column with `options` | a dropdown | the chosen string | | Everything else | text input | the typed string | Display widgets (`badge`, `tags`, `rating`, `progress` and others) and computed columns (`value`) are not editable by themselves; give a display column an `editor` to edit it. See [Custom editors](https://bonguynvan.github.io/bo-grid/docs/custom-editors/). ## Dropdowns Give an editable column `options` to pick from a list instead of typing: ```ts { type: 'text', key: 'status', header: 'Status', editable: true, options: ['New', 'Active', 'Closed'] } ``` ## Validation `validate(value, row)` runs before a value is saved; return `false` to refuse it and keep the old value. It sees the parsed value (a number for a number column), and it applies to paste and the fill handle too: ```ts { type: 'number', key: 'qty', header: 'Qty', editable: true, validate: (v) => Number(v) >= 0 } ``` To turn typed text into a value yourself (strip a currency sign, read "1.5k"), use `parse`; see [Custom editors](https://bonguynvan.github.io/bo-grid/docs/custom-editors/). ## Undo, paste and fill Edits, pastes and fills undo with Ctrl/⌘+Z and redo with Ctrl/⌘+Y (or Ctrl/⌘+Shift+Z); a paste or a fill is one step. Paste and the fill handle write through the same validation and call `onCellEdit` for every cell they change. See [Cell selection & clipboard](https://bonguynvan.github.io/bo-grid/docs/selection/). ## A full spreadsheet Dropdowns, parsed salaries, a date editor, the fill handle and saved layouts together: Live example: https://bonguynvan.github.io/bo-grid/examples/sheet/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference - `onCellEdit`: `(e: CellEditEvent) => void` — Called when an editable cell is committed. Update your row data in here. ```svelte (row[column.key] = value)} /> ``` - `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. - `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. - `options`: `string[]` (default `undefined`) — Editable choices: when set, editing renders a `` of these options instead of a text input (enum/status columns). ```ts { type: 'text', key: 'side', header: 'Side', editable: true, options: ['Buy', 'Sell'] } ``` - `defaultColumn`: `DefaultColumn` (default `undefined`) — Settings applied under every column — its own fields (and a registered `cellType`) win. ```svelte ``` - `cellTypes`: `Record` (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 Number(v).toLocaleString() } }} /> ``` - `onCellEdit`: `(e: CellEditEvent) => void` — Called when an editable cell is committed. Update your row data in here. ```svelte (row[column.key] = value)} /> ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/custom-editors/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Cell selection & clipboard > Select ranges of cells, copy and paste them as spreadsheet text, fill with a drag handle, and see live totals for a selection or the whole grid. Cells select like a spreadsheet: click a cell, then drag or Shift-click to select a rectangle. A selection copies as tab-separated text that pastes into a spreadsheet, and text from a spreadsheet pastes back into editable cells. A footer shows live statistics for the selected numbers. Drag across Price and Qty to see the sum and average below the grid, copy with Ctrl/⌘+C, or drag the small square at the selection's corner down to fill. The pinned last row totals every column. Example: Range selection, fill and totals (selection.svelte) ```svelte (e.row[e.column.key] = e.value)} /> ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Selecting - Click a cell; drag or Shift-click to extend to a rectangle. - Shift+arrows extend from the keyboard, and Ctrl/⌘+A selects everything. - Escape clears the selection. The full key map is in [Keyboard & hotkeys](https://bonguynvan.github.io/bo-grid/docs/keyboard/). ## Copy and paste Ctrl/⌘+C copies the selection as tab-separated text, which a spreadsheet pastes as cells. Ctrl/⌘+V pastes such a block into editable cells, starting at the selection's top-left corner: - A single copied value fills the whole selection. - A block is cut off at the grid's edges. - Values go through the same checks as typing: cells in columns that are not editable, and values that `parse` or `validate` refuse, are skipped. - Every accepted cell calls `onCellEdit`, so paste does nothing until you handle that callback. See [Inline editing](https://bonguynvan.github.io/bo-grid/docs/editing/). ## Fill handle `fillHandle` puts a small square on the selection's bottom-right corner. Drag it down or right to copy the selected values over the cells you drag across, as a spreadsheet does; a multi-cell selection repeats as a pattern. Only editable columns are filled, in-memory data only. ```svelte (e.row[e.column.key] = e.value)} /> ``` Edits, pastes and fills undo with Ctrl/⌘+Z and redo with Ctrl/⌘+Y; a paste or a fill undoes as one step. ## Totals for a selection When more than one cell is selected, a bar under the grid shows **Sum**, **Avg**, **Count**, **Min** and **Max** of the numbers in the range, and keeps them current as a live feed ticks. `aggregations` picks which to show: ```svelte ``` ## A totals row `footer` pins a totals row to the bottom: every column with a `groupAgg` shows that aggregate over all rows that pass the filters. In-memory data only. ```ts const columns: ColumnDef[] = [ { type: 'number', key: 'qty', header: 'Qty', groupAgg: 'sum' }, { type: 'price', key: 'price', header: 'Price', groupAgg: 'avg' }, ]; ``` ```svelte ``` The totals follow a live feed without rescanning every row: `api.patchRows` moves them by the rows it changed, and rows changed any other way (`$state` rows, an `onCellEdit` handler) update them too. A computed column must depend only on its own row for this; one that reads other rows or the clock gets a fresh total on the next view or `api.refresh()`. ## Read-only grids and an active row For a display grid, `cellSelection={false}` drops the selection highlight and the fill handle, so clicks go straight to `onRowClick` and `onCellClick`: ```svelte open(row)} /> ``` For a list beside a detail view, drive `selectedRowId` (by `getRowId`) to highlight the active row. It is separate from the cell selection and from checkbox [row selection](https://bonguynvan.github.io/bo-grid/docs/row-selection/): ```svelte (active = row)} /> ``` ## Pinned rows Rows can stay above the scroll, whether rows of your own (`pinnedRows`) or rows readers pin from the row menu (`rowPinning`): see [Pinned rows](https://bonguynvan.github.io/bo-grid/docs/pinned-rows/). ## Reference - `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. - `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. - `aggregations`: `AggKind[]` (default `['sum', 'avg', 'count', 'min', 'max']`) — Which stats the selection footer shows over a multi-cell range. ```svelte ``` - `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. - `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 (selectedRowId = row.id)} /> ``` - `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 column.key === 'sym' && openChart(row.sym)} /> ``` - `onCellEdit`: `(e: CellEditEvent) => void` — Called when an editable cell is committed. Update your row data in here. ```svelte (row[column.key] = value)} /> ``` - `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' } ``` - `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. - `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); ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/selection/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Keyboard & hotkeys > The grid's full keyboard model, and hotkeys of your own that act on the focused row, such as B to buy and Delete to cancel. The grid is one tab stop: Tab moves focus into it, and keys move within it, as in a spreadsheet. On top of the grid's own keys, `hotkeys` binds keys for your app (B to buy, S to sell, Delete to cancel) and hands each binding the row the user is on. Click a row, then press B, S or Delete; Shift+R brings the removed rows back. Example: Hotkeys on the focused row (keyboard.svelte) ```svelte

{log}

``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## The grid's keys | Keys | Action | | --- | --- | | ↑ ↓ ← → | Move the focused cell | | Shift + arrows | Extend the selection | | Home / End | First / last column; with Ctrl/⌘, the first / last cell | | PageUp / PageDown | Move by a page of rows | | Enter | Edit the focused cell, or run `onRowClick` on a read-only one | | F2 | Edit the focused cell | | A printable key | Start editing the focused cell with that character | | Space | Tick the focused row (with `rowSelection`) | | Ctrl/⌘+A / C / V | Select all / copy / paste | | Ctrl/⌘+Z / Y | Undo / redo an edit, paste or fill | | Ctrl/⌘+F | Open the find bar (with `findBar`) | | Alt+↓ | Open the column menu (with `columnMenu`) | | ContextMenu / Shift+F10 | Open the row menu (with `rowMenu` or `rowPinning`) | | → / ← on a tree row | Expand / collapse it | | Enter / Space / → / ← on a group header | Toggle / expand / collapse the group | | Escape | Clear the selection, or close a menu | While a cell is being edited, keys belong to the editor: Enter saves, Tab saves and moves to the next cell (Shift+Tab: the previous one), and Escape cancels. Each of them gives the keyboard back to the grid. Headers are focusable too: Enter or Space sorts, with Shift to add a sort key. Menus follow the usual menu pattern (arrows move, Enter chooses, Escape closes and returns focus). Screen-reader semantics are in [Accessibility](https://bonguynvan.github.io/bo-grid/docs/accessibility/). ## Hotkeys `hotkeys` maps a binding to a handler. Bindings run before the grid's own keys, and each handler gets the focused `row` and `column` (`null` when there is none) and the `event`: ```svelte ``` ### Writing a binding - A binding is a key (`'b'`, `'Delete'`, `'F2'`, `'Space'`, `'Enter'`) after any of `Shift+`, `Alt+`, `Ctrl+`, `Meta+` and `Mod+` (Ctrl or ⌘, whichever is pressed). Case does not matter. - A symbol is bound by the character it types: `'?'`, not `'Shift+/'` (which throws). - A letter also fires from its place on the keyboard when the layout types another alphabet there, so B still works on a Cyrillic or Greek layout. - A binding that cannot be read, or a key bound twice, throws when the grid gets the map. ### When a binding runs - Return `false` from a handler to leave the key to the grid, for example to keep the grid's own Enter on some rows: `Enter: ({ row }) => (row?.locked ? undefined : false)`. - Keys go to the grid, not to a binding, while a cell is being edited, or while an input, button or menu inside the grid has them. - A held-down key runs its binding once, so holding B opens one ticket. ## The focus stays on its row The selection and an open editor belong to rows, not to positions. When rows are added, removed or reordered (a new `rows` array, a row arriving above the focused one), the focus, the selected range and the editor move with their rows. A hotkey acts on the row the user is looking at, and an edit is written to the row it was opened on. If the focused row goes away, the selection clears; if the edited row goes away, the edit closes unsaved. (With a server `source`, rows are where the source puts them.) `api.getFocusedCell()` returns the same `{ row, column }` from code, and `api.focusCell(rowId, columnKey)` moves the focus. ## Reference - `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 { if (row) buy(row); }, 'Shift+C': cancelAll, Delete: ({ row }) => { if (row) cancel(row); } }} /> ``` - `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 (selectedRowId = row.id)} /> ``` - `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 [{ label: 'Cancel order', onSelect: () => cancel(row) }, { label: 'Copy id', onSelect: () => copy(String(row.id)) }]} /> ``` - `ariaLabel`: `string` (default `undefined`) — Accessible name for the grid (`aria-label` on the `role="grid"` root). - `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); ``` - `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); ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/keyboard/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Row grouping > Group rows by one or more columns into collapsible sections with live subtotals on each group header. `groupBy` groups the rows by the values of one or more columns: a portfolio by sector, orders by desk and then by trader. Each group has a header row that collapses it and shows subtotals for the columns that ask for one, and the subtotals stay current while the values tick. Click a group header to collapse it. The headers show the average price and change and the total volume of their rows. Example: Grouped by sector (grouping.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Grouping Pass the column keys to group by, outermost first: ```svelte ``` `groupBy` is a prop like any other: change it and the grid regroups. Grouping is for in-memory rows; for data grouped on a server, see [Server-side grouping](https://bonguynvan.github.io/bo-grid/docs/server-grouping/). ## Subtotals Give a column a `groupAgg` (`'sum'`, `'avg'`, `'count'`, `'min'` or `'max'`) and every group header shows that aggregate of the column over the group's rows: ```ts const columns: ColumnDef[] = [ { type: 'text', key: 'symbol', header: 'Symbol' }, { type: 'price', key: 'price', header: 'Avg price', groupAgg: 'avg' }, { type: 'volume', key: 'volume', header: 'Volume', groupAgg: 'sum' }, ]; ``` The same `groupAgg` drives the grand-total row when `footer` is on; see [Cell selection & clipboard](https://bonguynvan.github.io/bo-grid/docs/selection/). ## Large grouped sets Group headers are as tall as data rows, so a grouped grid virtualizes like a flat one and scrolls smoothly over many thousands of rows. While you scroll inside a group, its header stays pinned to the top, so you always know which group you are in. Subtotals recompute as a live feed changes values. ## A grouped portfolio Positions grouped by sector, with live subtotals and a P&L heatmap: Live example: https://bonguynvan.github.io/bo-grid/examples/portfolio/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference - `groupBy`: `string[]` (default `[]`) — Column keys to group rows by, outermost first (nested groups with subtotals). In-memory mode only. ```svelte ``` - `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. - `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' } ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/grouping/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Server-side grouping > Show groups summarised on the server, and load each group's rows only when it is expanded. When the data is grouped on the server, or is too large to send up front, give the grid the group summaries (`lazyGroups`) and a function that loads a group's rows (`loadGroup`). The grid shows the groups at once, with the counts and totals the server sent, and fetches a group's rows the first time it is opened. Expand a region: a loading row shows while its orders arrive, then they stay loaded. Live example: https://bonguynvan.github.io/bo-grid/examples/servergroups/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Group summaries and loading ```svelte ``` Each `LazyGroup` has: | Field | Meaning | | --- | --- | | `key` | The group's id, passed to `loadGroup` | | `label` | The header text (defaults to `key`) | | `count` | The row count shown on the header | | `agg` | Preformatted totals by column key, shown on the header under that column | `loadGroup(key)` returns a promise of the group's rows. While it runs the group shows a loading row; the rows are kept once loaded, so closing and reopening a group does not fetch again. The totals come from the server as strings, already formatted, because the grid has not seen the rows they sum. For groups computed in the browser from rows you already have, use [Row grouping](https://bonguynvan.github.io/bo-grid/docs/grouping/). ## Reference - `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 fetch(`/api/board/${key}`).then((r) => r.json())} /> ``` - `loadGroup`: `(key: string) => Promise` — Load a lazy group's leaf rows on expand (returns a promise; shows a loading row, then caches). Required with `lazyGroups`. ```svelte loadExchange(key)} /> ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/server-grouping/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Tree data > Show hierarchical rows as an expandable tree, from children you already have or loaded from a server on expand. Tree data shows rows that contain rows: folders and files, an org chart, accounts and sub-accounts. Tell the grid how to find a row's children, and `rows` become the roots of an indented tree with a chevron on every row that has children. Expand the folders with a click, or with → and ← on the focused row. Live example: https://bonguynvan.github.io/bo-grid/examples/tree/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Children you already have `getChildren` returns a row's children, or `undefined` (or an empty array) for a leaf: ```svelte r.children as GridRow[] | undefined} /> ``` The first column is indented by depth and carries the chevron. Every row in the tree needs an id unique across the whole tree, not just among its siblings. In tree mode the grid shows the tree as given: filtering, sorting, grouping and pagination do not apply to it. ## Children loaded on expand For a hierarchy too large to send up front, use `loadChildren` instead of `getChildren`. It returns a promise of a row's children and runs the first time the row is expanded; the grid shows a loading row meanwhile and keeps the children once loaded. Pair it with `hasChildren`, a cheap test that decides which rows get a chevron before anything loads: ```svelte r.kind === 'folder'} loadChildren={(r) => fetch(`/api/children/${r.id}`).then((res) => res.json())} /> ``` A server-backed tree, with a loading row on each first expand: Live example: https://bonguynvan.github.io/bo-grid/examples/lazytree/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Keyboard and screen readers With tree data the grid takes the treegrid role: rows expose `aria-level` and `aria-expanded`, → expands a collapsed row and ← collapses an expanded one. Other keys work as in a flat grid; see [Keyboard & hotkeys](https://bonguynvan.github.io/bo-grid/docs/keyboard/). ## Reference - `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 row.children as GridRow[] | undefined} /> ``` - `loadChildren`: `(row: GridRow) => Promise` — 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 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 row.kind === 'folder'} /> ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/tree-data/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Pivot tables > Turn flat rows into a pivot table, with row groups down the side, one column per value of a field, and an aggregate in each cell. `pivot()` turns flat rows into a pivot table: it groups rows by one or more fields, spreads the values of another field into columns, and aggregates a measure into each cell. It returns ordinary `rows` and `columns`, so the result goes straight into `` with sorting, selection and export working as usual. Trades by desk and book, one column per month plus a Total, in thousands. Change the aggregate to recompute every cell. Example: Notional by desk and month (pivot.svelte) ```svelte

``` pivot-data.ts: ```ts // Trades for the pivot example: one row per fill, by desk and month, notional in $ thousands. export type Trade = { id: number; desk: string; book: string; month: string; notional: number }; const DESKS = [ ['Equities', 'Cash'], ['Equities', 'Derivatives'], ['Rates', 'Govies'], ['FX', 'Spot'], ['FX', 'Forwards'], ] as const; const MONTHS = ['2026-01', '2026-02', '2026-03', '2026-04']; export function trades(): Trade[] { const out: Trade[] = []; let id = 1; for (const [desk, book] of DESKS) { for (const month of MONTHS) { // Two fills a month, sized from the ids so the table is the same on every load. for (let k = 0; k < 2; k++, id++) out.push({ id, desk, book, month, notional: ((id * 7919) % 900) + 50 }); } } return out; } ``` ## Building a pivot ```svelte ``` `pivot` is a pure function of its input. Call it once for a snapshot, or in `$derived` (as above) so the table follows the data and the settings. ## Options | Option | Meaning | | --- | --- | | `rowFields` | Fields to group rows by (at least one); each becomes a leading text column | | `columnField` | The field whose distinct values become columns, in sorted order; omit it for a single value column | | `measure` | The numeric field to aggregate | | `agg` | `'sum'` (default), `'avg'`, `'count'`, `'min'` or `'max'` | | `decimals` | Decimal places for the value columns (default 0) | | `totals` | Append a Total column across all column values (default `true`) | ## Pivot or grouping? A pivot reshapes the data into a new table with a column per value, which suits a report: months across, desks down. To keep the original rows and columns and fold them into collapsible sections with subtotals, use [Row grouping](https://bonguynvan.github.io/bo-grid/docs/grouping/). ## Reference `pivot(source, config)` returns `{ rows, columns }` (`PivotResult`). Its config is the `PivotConfig` type above. --- Page: https://bonguynvan.github.io/bo-grid/docs/pivot/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Live updates > Stream a market feed into the grid — plain rows patched in place, one batch a frame, with rows that keep their sort, go stale when a feed goes quiet and flash when they arrive. bo-grid is built for screens whose values change many times a second: price boards, watchlists, blotters. Values change in place, and the grid repaints only the cells that changed, flashing each one. Updates can reach the grid two ways. Write into `$state` rows, which is the simplest. For a busy feed, keep the rows as plain objects and hand the grid a batch of patches once a frame. This board takes 40 ticks a second through a tick stream into `api.patchRows`. It is sorted by change and stays sorted as prices move. Rows hold still while the pointer is over them. Example: A live board (realtime.svelte) ```svelte

{applied.toLocaleString()} ticks applied · sorted by Chg %, kept in order as prices move

(sort = s)} liveSort={800} onReady={(a) => (api = a)} /> ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` feed.ts: ```ts // A simulated market feed for the docs' examples: a random walk on each // quote's price. In an app, this is your socket's message handler. import type { Quote } from './data'; export type Tick = { price: number; change: number; volume: number }; /** Calls `onTick` about `perSecond` times a second (a number, or a function read on every beat, so the rate can change while it runs), for a random row each time. Returns a function that stops the feed. */ export function simulateFeed(rows: readonly Quote[], onTick: (id: number, tick: Tick) => void, perSecond: number | (() => number) = 20): () => void { const rate = typeof perSecond === 'function' ? perSecond : () => perSecond; const state = rows.map((r) => ({ id: r.id, ref: r.price / (1 + r.change / 100), price: r.price, volume: r.volume })); const every = 50; let due = 0; const timer = setInterval(() => { for (due += (rate() * every) / 1000; due >= 1; due--) { const s = state[Math.floor(Math.random() * state.length)]; s.price = Math.max(0.01, Math.round(s.price * (1 + (Math.random() - 0.5) * 0.006) * 100) / 100); s.volume += Math.ceil(Math.random() * 40) * 100; onTick(s.id, { price: s.price, change: Math.round(((s.price - s.ref) / s.ref) * 10_000) / 100, volume: s.volume }); } }, every); return () => clearInterval(timer); } ``` ## Two ways to update rows **`$state` rows.** Make the rows deep state and assign the new value. The grid sees the write and repaints that cell. A column with `flash: 'auto'` flashes it green or red (see [Cell flashing](https://bonguynvan.github.io/bo-grid/docs/flashing/)): ```svelte ``` This works well for a few hundred writes a second. Every field write goes through a reactive proxy, paid for every row the feed touches, on screen or not. **Plain rows and `api.patchRows`.** For a full price board (hundreds to thousands of symbols, several fields a tick), give the grid plain objects and let it write the ticks. A [tick stream](https://bonguynvan.github.io/bo-grid/docs/tick-stream/) coalesces the messages per key and hands the grid one batch a frame: ```svelte String(r.symbol)} onReady={(a) => (api = a)} height={480} /> ``` A patch is `[rowId, fields]`, where the id is the grid's `getRowId` (`row.id` by default). `patchRows` returns how many rows changed. Past about 10,000 messages a second, read the socket in a worker instead: see [Worker feeds](https://bonguynvan.github.io/bo-grid/docs/worker-feed/). ## What repaints `patchRows` writes into the rows and repaints only the rendered cells whose field changed. A tick that moves the price and the volume repaints those two cells, not the row. Rows off screen pay nothing beyond the write, and show the current values when they scroll in. Footer totals, group subtotals and selection sums stay live. Some cells can read other fields of the row, so they repaint whenever the row changes: - renderers: `render`, `component` and the `cell` snippet; - computed columns (`value`) and `sub` fields; - any `format`, `cellClass` or `tooltip` function that declares the row parameter. Write `(value, row) => …` only when the function reads the row, and `(value) => …` otherwise, so the cell skips ticks that don't concern it. `component` cells and the `cell` snippet update in place. On each patch they receive a fresh view of the row (same fields, new identity), so their markup and `$derived` values re-run while the component, its DOM and its local state stay. Read `row` reactively, not once at mount. ## Keeping the sort current Values that change in place do not re-sort or re-filter the view on every tick. For a board whose order must follow the feed (top movers, a watchlist sorted by change), set `liveSort`: ```svelte (api = a)} height={480} /> ``` 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` means 500). Only those rows are sorted and merged into the rest: on 100,000 rows with 500 changed, that takes about 5 ms against about 21 ms for a full re-sort. A row that changes other fields queues nothing. - Rows that stop matching a filter leave; rows that start matching join; a row whose group field changed moves to its new group. - The focused cell stays on its row as the row moves. - Nothing moves while a cell is being edited, or while the pointer is over the rows (`pauseOnHover`, on by default, for mouse and pen). Leaving the rows, the window losing focus or the tab hiding ends the pause. `liveSort` follows `patchRows`. Without it, or for values written into rows any other way, call `api.refresh()` to re-filter and re-sort the whole view against the current values. A row that keeps its position keeps its rendered cells. ## Stale and new rows - **`staleAfter`** (ms) dims a row when nothing new has come for it in that long: a quote whose feed went quiet. The row comes back the moment it ticks. News is a `patchRows` that changed the row, or a new object for it in `rows`; a patch that re-sends the same values is not news. Style it with `--bo-stale-opacity` (default 0.5). - **`flashNewRows`** flashes a row once when it arrives in `rows`, such as a new order or a new fill (`true` means 600 ms; or a number of ms). 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 receives (a load), nor a wholesale replacement. ```svelte (api = a)} height={480} /> ``` ## Busy screens The cost of a frame grows with the rows on screen, roughly linearly: on the Price board, 80 rows cost about 2.5 times what 30 do. Three settings keep a busy board inside its frame budget: - **The stream's `cap`** bounds the patches applied in one frame (400 by default). Ticks beyond it wait for the next frame instead of stretching this one. - **`flashMotion="hold"`** replaces the fading flash with a held tint, about 3 to 4 times less main-thread work under a busy feed. See [Cell flashing](https://bonguynvan.github.io/bo-grid/docs/flashing/#busy-boards). - **`updateMs`** on a column repaints it at most that often. Every tick is still written into the row at once, so sort, filters, totals and the other columns see it; the cell shows the latest value on its next beat. Use it for values read at a glance that change on every trade (total volume, foreign buy and sell), so they stop repainting every frame while the prices stay instant: ```ts { type: 'number', key: 'tv', header: 'Total vol', updateMs: 250 } ``` The first change after a quiet spell shows at once, and a flash plays on the repaint, against the value shown before. ## Measured On the Price board example (production build, 1,000 symbols, 24 columns, every changed cell flashing, about 30 rows on screen), a frame of a 30,000 events/s feed costs about 4.5 ms (p50) with `patchRows`, against about 5.3 ms through `$state` rows. The writes themselves are about 2.7 times cheaper (0.6 against 1.6 ms), and the gap grows with the number of symbols and fields a tick. The method and the full tables are in [BENCHMARKS.md](https://github.com/bonguynvan/bo-grid/blob/main/BENCHMARKS.md#browser-a-busy-price-board); the board's Benchmark button measures your machine. The full board, with three bid and ask levels, foreign flow and price-limit colours: Live example: https://bonguynvan.github.io/bo-grid/examples/priceboard/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference - `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 ``` - `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 ``` - `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 `${row.account}:${row.sym}`} /> ``` - `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 (api = a)} /> ``` - `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. - `patchRows(patches: Iterable]>): 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); ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/realtime/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Cell flashing > Flash a cell green or red when its value moves, show how far it moved, flash cells on demand, and keep a busy board's flashes cheap. A tick flash tells the eye which values just moved. Set `flash: 'auto'` on a column and the grid does the rest: it remembers each cell's last value and flashes green on a rise, red on a fall. There is nothing to keep in app code; write the new value and the cell flashes. Bid and ask below flash on their own, often in opposite directions in the same row. Status flashes amber whenever it changes. The buttons flash cells whose values did not change, and Motion switches between a fading and a held flash. Example: Flashing cells (flashing.svelte) ```svelte
(api = a)} /> ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Flash modes ```ts const columns: ColumnDef[] = [ { type: 'price', key: 'bid', header: 'Bid', flash: 'auto' }, { type: 'text', key: 'status', header: 'Status', flash: 'change', flashMs: 900 }, ]; row.bid = next; // the bid cell flashes, in the direction it moved ``` | `flash` | What flashes | | --- | --- | | `'auto'` (alias `'up-down'`) | Each cell on its own: green when the number rose, red when it fell. For prices and other numbers. | | `'change'` | Each cell on its own, in a neutral amber, on any change: status, quantity, text. | | `true` | Legacy row-driven mode: every `flash: true` column in the row flashes together, following the row's own `flashSeq` and `flashDir`, which you bump yourself. | The derived modes are per cell: bid can flash green while ask flashes red in the same row on the same frame. Flash state is keyed on the row id and the column, so a cell stays quiet on first paint and when it scrolls back into view; only a real change flashes. `flashMs` sets the duration (300 ms by default). Raise it for a slow feed, lower it for a fast one so flashes don't smear together. A column that colours its own text (price-limit tones, for example) should set `flashColor: false`, so the flash lights only the background. Under `prefers-reduced-motion: reduce` the grid plays no flash at all. ## Showing the change `showChange: true` shows how far a value moved, not only which way: `▲0.15` beside the price in the up colour (`▼` down), held, then faded over a second. ```ts { type: 'price', key: 'last', header: 'Last', flash: 'auto', showChange: true } { type: 'price', key: 'last', header: 'Last', showChange: { format: (d) => (d > 0 ? '+' : '') + d.toFixed(2), ms: 1500 } } ``` - The delta is written in the column's own format; `format(delta, row)` receives the signed change, and `ms` sets how long it shows (1,000 by default). - In a narrow column the delta gives way first, so the value is never cut for it. - It fades by animating the text colour, so it adds no compositor layers. Use it on the one or two columns that matter, such as the match price. Each change runs a short animation, so a busy board with it on every column pays real frame time. ## Flash on demand Some events don't change a value on screen: a block trade, your order filling, an alert level crossed. `api.flashCells` plays the tick flash on the cells you name, changed or not: ```ts api.flashCells({ rows: [fill.symbol] }); // the whole row, amber api.flashCells({ rows: ids, columns: ['symbol'], ms: 1200 }); // just the symbol, longer api.flashCells({ rows: [id], columns: ['last'], dir: 'up' }); // tinted like a rise ``` - `rows` are row ids (every rendered row when omitted), and `columns` are column keys (every column when omitted). - `dir: 'up' | 'down'` tints like a rise or a fall; `ms` overrides each column's `flashMs`. - Only rows on screen flash, as with a tick. The call returns how many rows it flashed. - Value cells flash (numbers and text). Cells that draw their own content (badges, sparklines, components) don't. - A tick that lands during the flash takes over with its own direction, and a repeat call replays it. ## Busy boards By default a flash fades out. A CSS fade is restyled and repainted on every frame it runs, and on a busy board most visible cells are always mid-fade, which becomes most of the frame. `flashMotion="hold"` shows a static tint for the flash window instead: repainted when it starts and when the grid's clock drops it, while a repeat tick during the hold changes nothing. ```svelte (api = a)} height={480} /> ``` Measured on the Price board with motion allowed, held flashes cut the main thread's work per frame from about 30 to about 9.4 ms at 10,000 events/s, and from about 40 to about 12.3 ms at 30,000, with no dropped frames. That is within about 1 ms of no flash at all ([BENCHMARKS.md](https://github.com/bonguynvan/bo-grid/blob/main/BENCHMARKS.md#live-frames-where-the-time-goes)). For a grid that is sometimes quiet and sometimes busy, `flashMotion="auto"` decides per flash as it starts. It fades while few cells flash at once and holds once more than about 120 overlap. A taller board reaches that sooner, which is the point: the cost grows with the fading cells on screen. A flash that lights whole rows (`flashNewRows`, `api.flashCells`) counts every cell it lights, so a burst of new orders on a blotter holds too. The trading desk board below flashes prices, changes and volume across 1,000 rows, with sparklines and heat cells: Live example: https://bonguynvan.github.io/bo-grid/examples/trading/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference - `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)}` } } ``` - `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. - `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 ``` - `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 }); ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/flashing/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Tick streams > Coalesce a feed's messages per key and apply one bounded batch a frame with createTickStream, so a burst never costs more than the screen can show. A market feed sends far more messages than a screen can show. Writing each one straight into rows means thousands of updates to paint 60 frames a second, and the tab stalls. `createTickStream` from `bo-grid/realtime` sits between the socket and the grid. Pushing a message into it is cheap: no reactive write, no render. Once a frame it hands the grid the latest patch for each key that changed, at most `cap` of them. Pick a feed rate below and compare the messages that come in with the patches the grid applies. At 5,000 messages a second over 12 rows, almost every message is merged into a patch still waiting for its frame. Example: Coalescing a feed (tick-stream.svelte) ```svelte
{stats.received.toLocaleString()} messages in {stats.applied.toLocaleString()} patches applied {stats.pending} waiting
``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` feed.ts: ```ts // A simulated market feed for the docs' examples: a random walk on each // quote's price. In an app, this is your socket's message handler. import type { Quote } from './data'; export type Tick = { price: number; change: number; volume: number }; /** Calls `onTick` about `perSecond` times a second (a number, or a function read on every beat, so the rate can change while it runs), for a random row each time. Returns a function that stops the feed. */ export function simulateFeed(rows: readonly Quote[], onTick: (id: number, tick: Tick) => void, perSecond: number | (() => number) = 20): () => void { const rate = typeof perSecond === 'function' ? perSecond : () => perSecond; const state = rows.map((r) => ({ id: r.id, ref: r.price / (1 + r.change / 100), price: r.price, volume: r.volume })); const every = 50; let due = 0; const timer = setInterval(() => { for (due += (rate() * every) / 1000; due >= 1; due--) { const s = state[Math.floor(Math.random() * state.length)]; s.price = Math.max(0.01, Math.round(s.price * (1 + (Math.random() - 0.5) * 0.006) * 100) / 100); s.volume += Math.ceil(Math.random() * 40) * 100; onTick(s.id, { price: s.price, change: Math.round(((s.price - s.ref) / s.ref) * 10_000) / 100, volume: s.volume }); } }, every); return () => clearInterval(timer); } ``` ## Creating a stream With plain rows, apply each batch through the grid's `patchRows` ([Live updates](https://bonguynvan.github.io/bo-grid/docs/realtime/) explains why it is the fast path): ```ts import { createTickStream } from 'bo-grid/realtime'; const stream = createTickStream>({ apply: (batch) => api.patchRows(batch), // once a frame, at most `cap` patches }); stream.start(); socket.onmessage = (e) => { const q = JSON.parse(e.data); stream.push(q.symbol, q); // merges into the symbol's waiting patch }; ``` With `$state` rows, index them once and write the batch with `applyPatches`, which skips fields whose value did not change, so a chatty feed that re-sends the same price neither re-renders nor flashes: ```ts import { applyPatches, createRowIndex, createTickStream } from 'bo-grid/realtime'; const index = createRowIndex(rows, (r) => r.symbol); // rebuild when symbols are added or removed const stream = createTickStream>({ apply: (batch) => applyPatches(index, batch), }); ``` The key you push under is the feed's key. The grid's flash and selection follow its own row id (`getRowId`, `row.id` by default): keep that stable across updates. ## Coalescing For each key, only the latest state is applied. Two patches that arrive for the same key between frames merge field by field when both are plain objects, so partial messages compose: `{ bid }` then `{ ask }` becomes `{ bid, ask }`. Anything else is last-write-wins. For updates that accumulate, pass `merge`: ```ts const stream = createTickStream({ apply: (batch) => api.patchRows(batch), merge: (prev, next) => ({ volume: prev.volume + next.volume }), // sum, don't replace }); ``` Keys drain in the order they first arrived, so a hot symbol cannot starve a quiet one: a key pushed again keeps its place in the queue. A price board wants the latest state. A trade tape is the opposite case, where every trade must show: append trades to a `TradeTape` instead (see [Depth ladder](https://bonguynvan.github.io/bo-grid/docs/depth-ladder/#time-and-sales)). ## Feed health - **`cap`** bounds the patches applied in one frame (400 by default). A burst cannot blow the frame budget. A sustained overload shows as a climbing `stream.pending` instead of dropped frames. - **`pending`** counts the keys waiting for a frame: your feed-health number. - **`applied`** counts the patches applied since the stream was created, a throughput counter. A batch whose `apply` threw is not counted. - **`stop()`** pauses draining and keeps what waits, so a paused stream resumes with the latest state; `stop({ discard: true })` drops it. **`flushNow()`** applies everything at once, ignoring the cap: for a tab regaining focus, or a last paint before teardown. If `apply` throws, that frame's batch is lost: it left the buffer before `apply` ran, and re-queuing it could apply it twice. The frame loop keeps going. Pass `onError` to handle the error; without it, it is re-thrown asynchronously so it does not vanish. ## Scheduling and tests The stream drains on `requestAnimationFrame`, falling back to a 16 ms timer where there is none (server rendering, tests). Pass `schedule` to drive the frames yourself; that is also how the stream is unit-tested without a browser: ```ts let frame: (() => void) | undefined; const stream = createTickStream({ apply, schedule: (fn) => ((frame = fn), () => (frame = undefined)) }); stream.start(); stream.push('VNM', { price: 68.6 }); frame?.(); // one frame: apply ran with [['VNM', { price: 68.6 }]] ``` `bo-grid/realtime` is a separate entry with its own bundle, and none of it depends on the grid: importing it adds nothing to the grid core. The live blotter below applies 200 fills a frame into 100,000 orders through a stream and `patchRows`: Live example: https://bonguynvan.github.io/bo-grid/examples/liveblotter/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference `createTickStream(options)` returns a `TickStream`. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `apply` | `(batch: Array<[K, P]>) => void` | required | Applies a frame's patches. Called at most once a frame, never with an empty batch. | | `cap` | `number` | `400` | Most patches applied in one frame. | | `merge` | `(prev: P, next: P) => P` | field-wise for plain objects | Combines two patches for one key between frames. | | `schedule` | `(fn) => cancel` | `requestAnimationFrame` | The frame scheduler. | | `onError` | `(err: unknown) => void` | re-thrown asynchronously | Hears an `apply` that threw. | | Member | What it is | | --- | --- | | `push(key, patch)` | Takes one message's patch. | | `start()` | Begins draining every frame. Idempotent. | | `stop(opts?)` | Stops draining; `{ discard: true }` drops the waiting patches. | | `flushNow()` | Applies everything waiting, ignoring the cap. | | `pending` | Keys waiting for a frame. | | `applied` | Patches applied since creation. | | `running` | Whether it is draining. | Helpers: `createRowIndex(rows, getRowId)` maps ids to rows; `applyPatches(index, batch)` writes a batch into indexed rows, skipping unchanged fields, and returns how many found a row. `TickBuffer` is the coalescing buffer on its own, for a loop you run yourself. --- Page: https://bonguynvan.github.io/bo-grid/docs/tick-stream/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Worker feeds > Read a busy socket in a Web Worker with serveFeed, and take one coalesced batch a frame on the main thread with connectFeed. Every socket message is a task on the thread that receives it. Up to a few thousand messages a second, receiving them on the main thread costs little, and a [tick stream](https://bonguynvan.github.io/bo-grid/docs/tick-stream/) there is all a board needs. Past about 10,000 a second, receiving the messages takes real time from the frames: parsing them, and above all dispatching each one as a task. `serveFeed` and `connectFeed` move that work to a worker. The worker reads the socket, parses and coalesces the messages, and the main thread takes one batch a frame. In the example, the worker generates and parses up to 20,000 JSON messages a second for 500 symbols. The page asks it for a batch each frame and hands it to `patchRows`. Raise the rate and the main thread does the same work: a batch a frame, never more than 400 patches. Example: A feed read in a worker (worker-feed.svelte) ```svelte
{stats.connected ? 'connected' : 'connecting…'} {stats.applied.toLocaleString()} patches applied {stats.pending} waiting in the worker
(api = a)} /> ``` worker-feed-data.ts: ```ts // The symbols the worker-feed example streams, shared by the page (its // first rows) and the worker (its simulated socket). export const SYMBOLS = 500; export type Tick = { price: number; change: number; volume: number }; /** A symbol's name and opening price, the same on both sides. */ export function seed(id: number): { symbol: string; price: number } { const letters = 'ABCDEFGHKLMNPRSTVX'; const symbol = letters[id % 18] + letters[Math.floor(id / 18) % 18] + letters[(id * 7) % 18]; return { symbol, price: 10 + ((id * 37) % 190) }; } ``` ## The two ends In the worker, `serveFeed` is the end the socket pushes into. Patches for a key merge until the main thread takes them, as in `createTickStream`: ```ts // feed.worker.ts import { serveFeed } from 'bo-grid/realtime'; const feed = serveFeed>(); const socket = new WebSocket(FEED_URL); socket.onmessage = (e) => { const m = JSON.parse(e.data); feed.push(m.symbol, { last: m.p, volume: m.v }); }; ``` On the page, `connectFeed` asks the worker for a batch every frame and applies it: ```ts import { connectFeed } from 'bo-grid/realtime'; const worker = new Worker(new URL('./feed.worker.ts', import.meta.url), { type: 'module' }); const feed = connectFeed>(worker, { apply: (batch) => api.patchRows(batch), // at most `cap` (400) patches, never an empty batch }); feed.start(); // feed.pending, feed.applied, feed.stop(), feed.close() ``` Each frame the page sends one request and the worker answers with what waits, so a batch is at most a frame old. A hidden tab stops asking; the worker keeps merging, and the first batch back is the latest state. ## Connecting - The two ends talk over a `MessagePort` of their own, so the worker can carry other messages too. - The page offers its port until `serveFeed` takes it, so the worker may set up its feed after an `await` (loading a symbol list first, say). - Use a dedicated `Worker`, not a `SharedWorker`. When one worker serves several feeds, give each a `name`, the same on both ends. A name serves once per worker; a second `serveFeed` with it throws. - `close()` stops the feed and closes its port; terminate the worker yourself when nothing else uses it. ## Patches and errors Patches cross by structured clone, so keep them plain data: numbers, strings, plain objects and arrays. A patch that cannot be cloned is dropped and reported to `onError` on both ends, and the rest of the batch still arrives. On the page, `onError` also hears when: - `apply` throws (that batch is lost, the frames go on); - no `serveFeed` answers within 5 s (a wrong name, or a worker that failed to load); - a connected worker leaves a request unanswered for 1 s. `feed.stalled` turns true, and the page asks again. `stop()` keeps what waits in the worker for the next `start()`; a batch already on its way is still applied. ## When it pays `pnpm bench:feed` runs the same feed (1,600 symbols, JSON messages) into the same rows both ways in headless Chrome. In one run on a laptop, measured as the share of the main thread kept busy: | Feed | Read on the main thread | Read in a worker | | ---: | ---: | ---: | | 10,000 messages/s | 11.2% | 3.3% | | 30,000 messages/s | 24.5% | 5.6% | | 100,000 messages/s | 62.8% | 5.1% | Runs vary with the machine's load (14–25% at 30,000 a second and 40–70% at 100,000 on the main thread in other runs), but the worker's share stays at 3–6%, and the latency from message to patch is the same either way (10–16 ms at the median). Parsing and merging are under a third of the main thread's cost; the rest is dispatching each message as a task. Method and full table: [BENCHMARKS.md](https://github.com/bonguynvan/bo-grid/blob/main/BENCHMARKS.md#feed-ingestion-main-thread-vs-worker). At a few thousand messages a second, `createTickStream` on the main thread is just as good, and simpler. ## Reference `serveFeed(options?)`, in the worker, returns a `FeedSource`. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `name` | `string` | `'feed'` | The feed's name, when a worker serves more than one. | | `merge` | `(prev: P, next: P) => P` | field-wise for plain objects | Combines two patches for one key. | | `onError` | `(err: unknown) => void` | none | Hears patches that could not be sent. | | `scope` | `FeedScope` | the worker's global scope | Where the connection arrives. | `FeedSource`: `push(key, patch)`, `pending` (keys waiting), `connected` (whether a page has connected). `connectFeed(worker, options)`, on the page, returns a `WorkerFeed`. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `apply` | `(batch: Array<[K, P]>) => void` | required | Applies a frame's batch. | | `cap` | `number` | `400` | Most patches in one batch. | | `name` | `string` | `'feed'` | The worker's feed, as named in `serveFeed`. | | `schedule` | `(fn) => cancel` | `requestAnimationFrame` | The frame scheduler. | | `onError` | `(err: unknown) => void` | re-thrown asynchronously | Hears failed batches, dropped patches, no server, stalls. | `WorkerFeed`: `start()`, `stop()`, `close()`, and `connected`, `stalled`, `pending` (keys waiting in the worker, as of the last batch), `applied`, `running`. --- Page: https://bonguynvan.github.io/bo-grid/docs/worker-feed/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Viewport streaming > Subscribe a feed to just the rows on screen with onViewportChange and createViewportSubscriptions, so a board of thousands of symbols streams only the few dozen in view. A board of 1,600 symbols shows 30 to 80 at a time. Streaming all of them costs the server, the network and the client for values nobody sees. The grid's `onViewportChange` reports the data rows on screen, and `createViewportSubscriptions` from `bo-grid/realtime` turns those reports into subscribe and unsubscribe calls for your feed. The board below has 1,000 symbols, but its simulated server sends ticks only for the subscribed ones. Scroll it: once the view settles, the feed follows the rows on screen. Example: Streaming the rows in view (viewport-streaming.svelte) ```svelte

Streaming {streaming} of {rows.length.toLocaleString()} symbols: scroll, and the feed follows the rows on screen.

(api = a)} /> ``` ## The rows on screen `onViewportChange` is called with the data rows on screen, plus the small buffer rendered above and below them, whenever that set changes: a scroll, a sort, a filter, new data. A tick that only changes values never calls it. Group rows, loading rows and pinned rows are not included. ```svelte console.log(view.first, view.last, view.rows.length)} /> ``` `first` and `last` are the visual indexes of the first and last row reported, or -1 when no data row is on screen; `rows` are the rows themselves, in view order. ## Subscribing what is in view ```svelte ``` - The first view subscribes at once. - Later changes settle for `delay` ms (150 by default), so a fast scroll is one change rather than one a frame. Then it subscribes the keys that came into view and unsubscribes the ones that left. While a scroll is in progress, the previous keys stay subscribed. - `flush()` applies a pending change at once; `clear()` unsubscribes everything and drops a pending change (on unmount, or when the socket closes); `active` is the set of keys subscribed now. - `subscribe` and `unsubscribe` are always called with at least one key. Pass every change to `update` without throttling it: the `delay` does the settling. Combine it with a [tick stream](https://bonguynvan.github.io/bo-grid/docs/tick-stream/) or a [worker feed](https://bonguynvan.github.io/bo-grid/docs/worker-feed/) for the ticks that come back: the grid applies them the same way whether a row is on screen or not, and rows that scroll in show their latest values. ## Reference - `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 ``` `createViewportSubscriptions(options)` returns a `ViewportSubscriptions`. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `key` | `(row: R) => K` | required | The feed key of a row (its symbol). | | `subscribe` | `(keys: K[]) => void` | required | Starts streaming these keys. | | `unsubscribe` | `(keys: K[]) => void` | required | Stops streaming these keys. | | `delay` | `number` | `150` | How long the view must stay put before a change applies, in ms; `0` applies every change at once. | | Member | What it is | | --- | --- | | `update(view)` | Takes a view change: pass it as the grid's `onViewportChange`. | | `flush()` | Applies a pending change now. | | `clear()` | Unsubscribes everything and drops a pending change. | | `active` | The keys subscribed now. | --- Page: https://bonguynvan.github.io/bo-grid/docs/viewport-streaming/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Market conventions > Price-limit colours, tick sizes, session state, FX pips, crypto prices, basis points and 32nds — the market conventions of bo-grid/trading, as plain functions you wire into columns. Markets have conventions a generic grid knows nothing about. A Vietnamese board colours a price by where it sits in the day's price limit. A dealing screen shows an FX quote with its pips large. A Treasury price is written in 32nds. `bo-grid/trading` holds these conventions as plain functions: no component, and nothing added to `ColumnDef`. You wire them into a column through the hooks it already has: `format`, `cellClass`, `render` and `parse`. It is a separate entry, so an app that does not import it does not pay for it. The board below colours each symbol and price by its price-limit tone: purple at the ceiling, cyan at the floor, yellow at the reference, green or red for an ordinary move. Prices step on the exchange's tick table and stay inside the daily band. Example: Price-limit tones (trading.svelte) ```svelte

HOSE session now: {sessionLabel(sessionStateAt(new Date(), VN_HOSE_SCHEDULE))} · prices move on the tick table, inside the daily band

(api = a)} />
``` ## Price-limit tones APAC exchanges cap a day's move at a band around the reference price. Traders read a price by its place in that band, not only by its direction: ```ts import { vnBands, resolveTone, toneColor, lightToneColors } from 'bo-grid/trading'; const bands = vnBands(68_500, 'HOSE'); // { ref: 68500, ceiling: 73200, floor: 63800 } const tone = resolveTone(price, bands); // 'ceiling' | 'floor' | 'ref' | 'up' | 'down' toneColor(tone); // the dark palette's colour toneColor(tone, lightToneColors); // on a light theme ``` - `vnBands` knows HOSE, HNX and UPCOM's daily bands (7%, 10% and 15%). It rounds the ceiling down and the floor up to a tradable tick, so neither sits outside the real limit. VN30 index futures trade on a 0.1-point step with a ±7% band: `vnFuturesBands(ref)` gives their ceiling and floor. - For any other market, pass your own `{ ref, ceiling, floor }` to `resolveTone`: the bands come from your reference-data feed. - `darkToneColors` and `lightToneColors` keep every tone at WCAG AA (4.5:1) on the grid's presets for their side. `toneColor(tone, colours)` takes a whole palette or a partial one that overrides single tones. Colour a column with `cellClass` and a class per tone. Set `flashColor: false` on a flashing column whose text carries the tone, so a flash lights only the background: ```ts const tone = (_v: unknown, row: GridRow) => `tone-${resolveTone(Number(row.price), vnBands(Number(row.ref)))}`; const columns: ColumnDef[] = [ { type: 'text', key: 'symbol', header: 'Symbol', cellClass: tone }, { type: 'number', key: 'price', header: 'Price', cellClass: tone, flash: 'auto', flashColor: false }, ]; ``` ```css :global(.bo-grid .c.tone-ceiling) { color: #e879f9; } /* and floor, ref, up, down */ ``` ## Tick sizes and prices ```ts import { vnTickSize, roundToTick, fmtTradingPrice, decimalsForTick } from 'bo-grid/trading'; vnTickSize(68_500, 'HOSE'); // 100: HOSE's step widens as the price rises roundToTick(68_537, 100, 'down'); // 68500 ('nearest' by default, or 'up') fmtTradingPrice(68_500, 100); // '68,500', not '68500.00' fmtTradingPrice(1.08452, 0.00001); // '1.08452': the tick's decimals decimalsForTick(0.05); // 2 ``` HOSE prices step by 10, 50 or 100 VND depending on the price; HNX and UPCOM use a flat 100. A ceiling or an order at a price off the tick is not tradable, which is why `vnBands` rounds to it. `fmtTradingPrice(price, VN_FUTURES_TICK)` shows a futures price with one decimal. ## Sessions ```ts import { sessionStateAt, sessionLabel, VN_HOSE_SCHEDULE } from 'bo-grid/trading'; const state = sessionStateAt(new Date(), VN_HOSE_SCHEDULE); // 'pre-open' | 'ato' | 'continuous' | 'break' | 'atc' | 'closed' sessionLabel(state); // 'ATO', 'Continuous', … ``` `sessionStateAt` reads the schedule in the exchange's own time zone (`Asia/Ho_Chi_Minh` by default), so a trader in New York sees Ho Chi Minh City's session, not their own. Weekends read `'closed'` unless `skipWeekends: false`. A schedule is a list of `{ start, end, state }` windows in `HH:mm` that tile the day; write your own for another exchange and pass its `timeZone`. Holidays and half-days are not in the schedule: check them against your own trading calendar. ## FX A pair's pip is 0.01 when the quote currency is the yen and 0.0001 otherwise. A dealing screen shows a quote with the big figure small, the pips large and the pipette raised: Example: FX quotes (trading-fx.svelte) ```svelte

FX market {fxMarketOpen(new Date()) ? 'open' : 'closed'} · centres open now: {fxCentresOpen(new Date()).join(', ') || 'none'}

(api = a)} /> ``` ```ts import { fxPipSize, fxQuoteParts, fxQuoteHtml, pipsBetween, fxMarketOpen, fxCentresOpen } from 'bo-grid/trading'; fxPipSize('USDJPY.m'); // 0.01: a broker's suffix is fine fxQuoteParts(1.08452, 'EUR/USD'); // { big: '1.08', pips: '45', pipette: '2' } pipsBetween(ask, bid, 'EUR/USD'); // the spread in pips, to a tenth fxMarketOpen(new Date()); // Sunday 17:00 to Friday 17:00, New York time fxCentresOpen(new Date()); // e.g. ['London', 'New York']: the overlap const bidColumn: ColumnDef = { type: 'custom', key: 'bid', header: 'Bid', render: ({ row }) => fxQuoteHtml(Number(row.bid), String(row.pair)), // big figure, pips, pipette }; ``` - Pass a pip size below 1 instead of a pair for metals, currencies with another pip (HUF, KRW) or a broker's own convention. - `fxQuoteHtml` puts each part in a class of its own (`bo-fx-big`, `bo-fx-pips`, `bo-fx-pipette`) with inline defaults you can override. - The centres are Sydney, Tokyo, London and New York in their own business hours, so summer time moves them as it does on the street. ## Crypto The crypto market never closes: `sessionStateAt(now, CRYPTO_SCHEDULE, { timeZone: 'UTC', skipWeekends: false })` always reads `'continuous'`. One watchlist runs from 63,250 down to 0.0000012, where no fixed number of decimals suits every row: ```ts import { fmtSigPrice, fmtTradingPrice, roundToTick } from 'bo-grid/trading'; fmtSigPrice(63_250.42); // '63,250': five significant digits, and every digit of the integer part fmtSigPrice(0.0000123456); // '0.000012346' fmtTradingPrice(0.5123, 0.0001); // where you know the symbol's tick, show it exactly roundToTick(0.12345, 0.001, 'down'); // a size rounded down to its lot step ``` ## Basis points and 32nds `fmtBps(fraction)` shows a fraction in basis points: pass `(ask - bid) / mid` for a spread, or `(y1 - y0) / 100` for a move in a yield quoted in percent. Treasuries are quoted as a whole number and 32nds; `fmtFraction` writes them and `parseFraction` reads them back, so a price column can be edited the way it is quoted: ```ts import { fmtBps, fmtFraction, parseFraction } from 'bo-grid/trading'; fmtBps(0.00125); // '12.5 bp' fmtBps(-0.0005, { sign: true }); // '-5.0 bp'; unit: '' when the header says bp fmtFraction(99.515625, { sub: 2 }); // '99-16+': 99 and 16½ 32nds fmtFraction(99.5078125, { sub: 4 }); // '99-162': quarters as 2, + and 6 fmtFraction(99.5078125, { sub: 4, style: 'futures' }); // "99'162": quarters as 2, 5 and 7 fmtFraction(450.25, { denominator: 8, separator: "'" }); // "450'2": a grain price in eighths const quote = { sub: 2 } as const; const px: ColumnDef = { type: 'number', key: 'px', header: 'Price', editable: true, format: (v) => fmtFraction(Number(v), quote), parse: (raw) => parseFraction(raw, quote), // '99-16+' or 99.515625; anything else is refused }; ``` Cash Treasuries write the last character in eighths of a 32nd (`+` for the half), while futures screens write its first decimal digit (`5` for the half), so `'99-165'` and `"99'165"` are different prices. Give `parseFraction` the same options the column formats with, so an order is never entered at the other one. ## The full boards A HOSE board with price-limit colours, tick-aware formatting and a live session badge, fed through `bo-grid/realtime`: Live example: https://bonguynvan.github.io/bo-grid/examples/vnboard/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) FX pairs on a dealing-screen quote, and a crypto watchlist with alerts: Live example: https://bonguynvan.github.io/bo-grid/examples/fxcrypto/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference | Function | What it returns | | --- | --- | | `vnBands(ref, exchange?)` | `{ ref, ceiling, floor }` for HOSE (default), HNX or UPCOM, tick-rounded. | | `vnFuturesBands(ref)` | The VN30 futures band (`VN_FUTURES_BAND`, ±7%) on its 0.1 step (`VN_FUTURES_TICK`). | | `vnTickSize(price, exchange?)` | The price step at this price. | | `vnBandPercent(exchange)` | The daily band as a fraction: 0.07, 0.1 or 0.15. | | `roundToTick(price, tick, mode?)` | The price on the tick: `'nearest'` (default), `'down'` or `'up'`. | | `resolveTone(price, bands, opts?)` | The tone: `'ceiling'`, `'floor'`, `'ref'`, `'up'` or `'down'`. | | `toneColor(tone, colours?)` | A CSS colour; `darkToneColors` (also `defaultToneColors`) unless given a palette. | | `fmtTradingPrice(price, tick, locale?)` | The price with its tick's decimals, grouped. | | `decimalsForTick(tick)` | The decimals a tick needs. | | `sessionStateAt(date, schedule, opts?)` | The session state; `opts`: `timeZone`, `skipWeekends`. | | `sessionLabel(state)` | A display label for a state. | | `fxPipSize(pair)` | 0.01 for yen-quoted pairs, else 0.0001. | | `fxQuoteParts(price, pip, opts?)` | `{ big, pips, pipette }`; `pipette: false` stops at the pip. | | `fxQuoteHtml(price, pip, opts?)` | The same parts as HTML. | | `pipsBetween(a, b, pip)` | `a - b` in pips, to a tenth. | | `fxMarketOpen(date)` | Whether the FX week is open. | | `fxCentresOpen(date)` | The centres in business hours. | | `fmtSigPrice(price, digits?, locale?)` | The price to 5 (or `digits`) significant digits. | | `fmtBps(fraction, opts?)` | Basis points; `opts`: `decimals`, `sign`, `unit`, `locale`. | | `fmtFraction(price, opts?)` | A whole number and parts; `opts`: `denominator`, `sub`, `style`, `separator`. | | `parseFraction(text, opts?)` | The price a fraction or a decimal means; `NaN` for anything else, which refuses an edit. | Positions from fills (`createPositions`, `unrealizedPnl`) are on [Orders & positions](https://bonguynvan.github.io/bo-grid/docs/orders-positions/). --- Page: https://bonguynvan.github.io/bo-grid/docs/trading/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Order book > Keep an order book from a depth feed with createOrderBook — snapshot, incremental updates with sequence checks, grouping — and show it as stacked or side-by-side grid rows. A depth feed sends a snapshot of the book, then the price levels that change. `createOrderBook` from `bo-grid/realtime` keeps that book: it applies the changes as they arrive, checks their sequence numbers, and reads back the best levels a side when a frame draws, merged into price buckets on request and shaped as rows for the grid. The book below is read once a frame. Sizes flash as levels change, the depth bars show the running total from the spread outward, and Group merges levels into buckets. Example: An order book (order-book.svelte) ```svelte
``` ## Keeping the book ```ts import { createOrderBook } from 'bo-grid/realtime'; const book = createOrderBook({ maxLevels: 1000 }); book.snapshot({ bids, asks, seq: snap.lastUpdateId }); // e.g. from REST socket.onmessage = (e) => { const m = JSON.parse(e.data); // [price, size] levels, as numbers or numeric strings; a size of 0 removes a level const result = book.update({ bids: m.b, asks: m.a, from: m.U, seq: m.u }); if (result === 'gap') resnapshot(); // a message was missed }; ``` `update` returns `'applied'`, `'stale'` or `'gap'`: - **`seq`** is the update's last sequence number. An update that is not newer than the last one applied is dropped as `'stale'`, as a feed's diffs from before the snapshot must be. - **`from`** is the first sequence number the update covers, for a feed that numbers its updates in an unbroken run. When it is past the one after the last applied, `update` returns `'gap'` and applies nothing: take a new snapshot. - Without `from`, a missed update goes unnoticed unless it crosses the book: `crossed` turns true when the best bid reaches the best ask. - `snapshot` always replaces the whole book, whatever its `seq`. `clear()` empties it and forgets the sequence. ## Reading it once a frame Apply updates as they arrive, and read the book when a frame draws. `version` bumps on every change a read can see, so an unchanged book costs nothing: ```ts let seen = -1; function frame() { if (book.version !== seen) { seen = book.version; rows = book.rows(20, { group: 0.5 }); // stacked: asks above bids } requestAnimationFrame(frame); } ``` Three reads, each up to `n` levels a side: - **`rows(n)`**: stacked rows, the asks (farthest first) above the bids, each with `side`, `price`, `size`, `total` (depth from the spread outward), `share` (that depth as a share of the deeper side, for bars on one scale) and `best`. - **`pairs(n)`**: the nth bid beside the nth ask in one row (`bidPrice`, `bidSize`, `bidTotal`, `bidShare`, then the same for the ask), for a side-by-side book. - **`top(n)`**: the levels themselves, `{ bids, asks }` as `[price, size]` pairs, best first. The same levels feed a [depth ladder](https://bonguynvan.github.io/bo-grid/docs/depth-ladder/): `bids={top.bids} asks={top.asks}`. `best()` gives the best bid and ask, and `levels` counts each side. ## Showing it in the grid ```svelte String(r.side)} columns={[ { type: 'number', key: 'price', header: 'Price' }, { type: 'volume', key: 'size', header: 'Size', flash: 'auto' }, { type: 'number', key: 'share', header: 'Depth', format: (_v, r) => String(r?.total ?? ''), dataBar: { min: 0, max: 1 } }, ]} height={480} /> ``` - In `rows`, a level keeps its row id while it stays in the book, so a size column with `flash: 'auto'` flashes the levels that changed, not every row the book moved past. Each grouping has ids of its own, so switching it flashes nothing. - In `pairs`, the row id is the depth (0 at the spread), so a row's level changes as the book moves: don't flash those columns by value. - A `dataBar` on `share` with `min: 0, max: 1` draws the depth bars of both sides on one scale. ## Options - **`group`** (a read option) merges levels into buckets of that size: 0.1, 1 or 10 on a crypto book. Bids round down and asks up, so the two sides never meet in one bucket. - **`maxLevels`** keeps only a side's best levels and drops the far ones, in batches, for a feed that never removes them. A dropped level comes back only with the next snapshot, so keep it well above the depth you show. - Prices compare to 1e-8, as the depth ladder's do: scale a book quoted finer than that. The order book example, with a stacked and a side-by-side book from one feed: Live example: https://bonguynvan.github.io/bo-grid/examples/orderbook/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference `createOrderBook(options?)` returns an `OrderBook`. Its option is `maxLevels` (read at most this many levels a side). | Member | What it does | | --- | --- | | `snapshot({ bids, asks, seq? })` | Replaces the whole book. | | `update({ bids?, asks?, seq?, from? })` | Applies changed levels; returns `'applied'`, `'stale'` or `'gap'`. | | `rows(n, { group? })` | Stacked rows (`BookRow`): asks, then bids. | | `pairs(n, { group? })` | Side-by-side rows (`BookPairRow`). | | `top(n, { group? })` | `{ bids, asks }`, best first. | | `best()` | `{ bid?, ask? }`. | | `crossed` | Whether the best bid is at or above the best ask. | | `levels` | `{ bids, asks }`: levels a side. | | `version` | Bumps on every change a read can see. | | `seq` | The last sequence number applied. | | `clear()` | Empties the book. | --- Page: https://bonguynvan.github.io/bo-grid/docs/order-book/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Depth ladder > A price ladder with click-to-trade (DepthLadder), the windowing math for a ladder of your own, and a capped time and sales tape. A depth ladder (depth of market) shows one row per price on the tick grid, highest first, with the bid and ask sizes resting at each price, their depth from the best price outward, and your own working orders beside each side. A trader clicks a price to place an order there. `DepthLadder` from `bo-grid/ladder` is that ladder, built on the grid. It is a separate entry, so an app pays for it only when it imports it. Try it below: click a bid size to buy at that price, an ask size to sell, your own order to cancel it. With a row focused, B, S and Delete do the same. The wheel pages through prices and stops the ladder following the market; Space recentres it. Example: A depth ladder (depth-ladder.svelte) ```svelte

Click a bid size to buy, an ask size to sell, your order to cancel (or B, S, Delete on the focused row). The wheel pages; Space recentres. {following ? 'Following the market.' : 'Paged away.'}

``` ## Using DepthLadder ```svelte vnTickSize(p, 'HOSE')} bind:following {onAction} /> ``` - **The book.** `bids` and `asks` are `[price, size]` pairs in any order; an [order book](https://bonguynvan.github.io/bo-grid/docs/order-book/)'s `top(n)` gives them. A price that repeats is summed, and float noise in a feed still meets its level. `tick` is one step size, or a function of the price for a tick table; the step down from a band's first price uses the band below. - **Your orders.** `orders` are `{ id, side, price, qty }`, summed per price beside their side. - **Looks.** Ask prices in the down colour, bid prices in the up colour, the best bid and ask in bold, the last trade's row tinted, and the depth bars of both sides on one scale, so a lopsided book looks lopsided. `rows`, `rowHeight`, `format`, `locale`, `headers` and `theme` adjust it. ## Following the market The window centres on the middle of the spread (`center="last"` follows the last trade). It recentres only once the market leaves the middle half of the window, so the rows sit still between moves. It never moves while the pointer is over it or a cell has focus, so a price cannot shift under a click or a key. The wheel pages through prices and turns `following` off; bind it to show that state. Space, setting `following` back to `true`, or calling `recenter()` (through `bind:this`) centres on the market again. A click focuses the ladder without holding it once the pointer leaves; keyboard work in it does hold it. ## Order entry The ladder places nothing itself: `onAction` hears what the user asked for, and your app talks to the venue. - A click on the bid side is `{ kind: 'buy', price }`, on the ask side `{ kind: 'sell', price }`, and on your own orders `{ kind: 'cancel', price, side, orders }`. - B, S and Delete do the same on the focused row, once per press: a held key does not repeat it. Delete cancels your orders on both sides of the price. - Each action carries the price of the row as it was shown, and `via: 'click' | 'key'`. Show an order on the ladder once your venue acknowledges it. ## A ladder of your own The helpers behind `DepthLadder` are exported from `bo-grid/ladder` for a ladder of another shape: `ladderPrices(center, count, tick)`, `ladderRows(prices, input)`, `midPrice`, `bestPrices`, `ordersAt`, `snapToTick` and `stepPrice`. For a ladder drawn with `` directly, `centeredWindow` from `bo-grid/realtime` is the windowing math: hand the grid a different slice of the same levels each tick, instead of scrolling a long list to a position. ```ts import { centeredWindow } from 'bo-grid/realtime'; // 120 price levels, 16 visible, centred on the spread (index 60): const { start, end } = centeredWindow(120, 60, 16); // { start: 52, end: 68 } const visible = levels.slice(start, end); // the rows for ``` Following the market or holding still is state you keep yourself: a `lockedCenter: number | null`, where `null` follows the live centre and a number freezes the window until the user recentres. The price ladder example below shows the pattern: Live example: https://bonguynvan.github.io/bo-grid/examples/ladder/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Time and sales A time and sales tape is the opposite of a price feed: nothing coalesces, because every trade must show. What it needs is a bound. `TradeTape` from `bo-grid/realtime` is a ring buffer that keeps the newest `capacity` trades (500 by default), O(1) per push however long the session runs: ```ts import { TradeTape } from 'bo-grid/realtime'; const tape = new TradeTape(500); socket.onmessage = (e) => tape.push(JSON.parse(e.data)); rows = tape.toArray(); // newest first: call it once a frame, not once a trade ``` `toArray()` returns a fresh array, newest trade first; `size` counts the trades held and `clear()` empties the tape. In the hot-path benchmarks, a million pushes into a 500-trade tape take about 9 ms ([BENCHMARKS.md](https://github.com/bonguynvan/bo-grid/blob/main/BENCHMARKS.md#hot-paths)). Live example: https://bonguynvan.github.io/bo-grid/examples/timesales/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference `` props: | Prop | Type | Default | What it does | | --- | --- | --- | --- | | `bids`, `asks` | `[price, size][]` | required | Resting levels, in any order. | | `tick` | `number`, or `(price) => number` | required | The price step, or a function of the price. | | `last` | `number` | none | The last trade's price: its row is marked. | | `orders` | `LadderOrder[]` | `[]` | Your working orders. | | `rows` | `number` | `21` | Prices on screen. | | `rowHeight` | `number` | `24` | Row height in px. | | `center` | `'mid'` or `'last'` | `'mid'` | What the ladder follows. | | `following` | `boolean` (bindable) | `true` | Whether it follows the market. | | `format` | `(price) => string` | the tick's decimals | Formats a price. | | `locale` | `string` | `'en-US'` | Locale for the default formats. | | `headers` | `LadderHeaders` | none | Column headers: `myBid`, `bid`, `price`, `ask`, `myAsk`. | | `onAction` | `(action, event) => void` | none | A buy, sell or cancel the user asked for. | | `theme` | `'dark'`, `'light'` or a `GridTheme` | as on `` | The grid theme. | | `ariaLabel` | `string` | `'Price ladder'` | Accessible name. | `TradeTape(capacity?)`: `push(trade)`, `toArray()`, `size`, `capacity`, `clear()`. `centeredWindow(total, center, visible)`: `{ start, end }`. --- Page: https://bonguynvan.github.io/bo-grid/docs/depth-ladder/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Orders & positions > An order blotter with status chips, new orders that flash, done orders that linger a moment (createLinger), and positions with realized and unrealized P&L from the fills (createPositions). An order blotter lists working orders as they arrive, change and complete. It needs a few things beyond the grid itself. Status chips are a `badge` column. A new order flashes once with `flashNewRows`, and footer totals follow the feed. Two helpers cover the rest: `createLinger` keeps a done order on screen a moment, so it does not vanish from under the eye, and `createPositions` keeps each symbol's position from the fills. New orders below flash as they arrive. A filled order stays 1.5 seconds, dimmed, before it leaves, and each fill moves the position above the grid. Example: Orders and a position (orders-positions.svelte) ```svelte

FPT position {position?.qty ?? 0} at {position ? position.avgPrice.toFixed(2) : '–'} · realized {position?.realized.toFixed(0) ?? 0} · unrealized at 132: {position ? unrealizedPnl(position, 132).toFixed(0) : 0}

``` ## The blotter ```ts const columns: ColumnDef[] = [ { type: 'text', key: 'symbol', header: 'Symbol' }, { type: 'badge', key: 'side', header: 'Side', tones: { buy: 'up', sell: 'down' } }, { type: 'number', key: 'qty', header: 'Qty', groupAgg: 'sum' }, { type: 'badge', key: 'status', header: 'Status', tones: { Working: 'info', Filled: 'up', Cancelled: 'neutral' } }, ]; ``` ```svelte ``` - A `badge` column maps each value to a tone: `'up'`, `'down'`, `'amber'`, `'info'` or `'neutral'`. - `flashNewRows` flashes a row once when its id is new: an order that arrived, not one that changed. See [Live updates](https://bonguynvan.github.io/bo-grid/docs/realtime/#stale-and-new-rows). - `footer` shows a totals row; each column with a `groupAgg` shows that aggregate over all rows, and it stays live as orders change. ## Orders that linger When an order completes and leaves your list, `createLinger` keeps it on screen for `ms` more, in place, so nothing else moves until it goes: ```ts import { createLinger } from 'bo-grid/realtime'; let expired = $state(0); const linger = createLinger({ ms: 1500, key: (o) => o.id, onExpire: () => expired++ }); const shown = $derived.by(() => { void expired; // updates again when a row is due to go return linger.update(orders); // the orders, plus the ones that just left, in place }); const rowClass = $derived.by(() => { void shown; // a new function per update, so the grid restyles the rows leaving return (o: GridRow) => (linger.leaving(o) ? 'leaving' : undefined); }); ``` - `update(rows)` returns the rows you give it, plus those that left within `ms`, each right after the row it followed. A row that comes back is simply there again. - `onExpire` runs when a lingering row is due to go: call `update` again (above, by bumping `expired`). - Keys must be unique. `leaving(row)` compares the row object as `update` returned it. - To show an order as Filled while it lingers, mark it Filled first and drop it from your list on the next update: it lingers as it was last shown. - Rows on their way out count in footer totals like any other. `stop()` drops them and the pending `onExpire`. ## Positions from fills ```ts import { createPositions, unrealizedPnl } from 'bo-grid/trading'; const book = createPositions(); book.fill({ symbol: 'FPT', side: 'buy', qty: 400, price: 11.5 }); book.fill({ symbol: 'FPT', side: 'sell', qty: 100, price: 13 }); // realizes 150 const p = book.get('FPT'); // { symbol: 'FPT', qty: 300, avgPrice: 11.5, realized: 150, bought: 400, sold: 100 } if (p) unrealizedPnl(p, 12); // 150: the open 300 marked at 12 ``` - Each fill moves the symbol's position on average cost. A fill against the position realizes P&L on the part it closes, at the average price. One that goes through flat opens the rest on the other side, at the fill price. - `fee` comes off realized P&L; a negative fee (a rebate) adds to it. - Fractional sizes that add up to flat are flat, with no dust left open. - A fill with an empty symbol, a side other than `'buy'` or `'sell'`, a quantity not above 0, or a price or fee that is not a number throws, so a bad feed message is seen rather than booked. - `all()` lists every position, and `clear()` empties the book. **Live P&L.** Keep the last price as a field of each position row and compute unrealized P&L in a computed column. It depends only on its own row, so a `groupAgg: 'sum'` footer follows every tick: ```ts { type: 'number', key: 'upnl', header: 'Unrealized', groupAgg: 'sum', value: (r) => unrealizedPnl({ qty: Number(r.qty), avgPrice: Number(r.avgPrice) }, Number(r.last)) } ``` The full blotter: orders with status chips, cancel on C, and positions with live P&L: Live example: https://bonguynvan.github.io/bo-grid/examples/orders/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference - `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 ``` - `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. - `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 (row.status === 'Rejected' ? 'rejected' : undefined)} /> ``` - `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' } ``` - `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 } ``` `createLinger({ ms, key, onExpire? })` returns `{ update(rows), leaving(row), size, stop() }`. `createPositions()` returns `{ fill(f), get(symbol), all(), clear() }`; a fill is `{ symbol, side, qty, price, fee? }`, and a position is `{ symbol, qty, avgPrice, realized, bought, sold }` (`qty` below 0 when short). `unrealizedPnl(position, last)` marks the open quantity at `last`. --- Page: https://bonguynvan.github.io/bo-grid/docs/orders-positions/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Alerts > Price alerts that fire once per crossing and stay lit until someone acknowledges them, with createAlerts from bo-grid/realtime. An alert on a trading screen must not be missed. A price that touches its level and comes back has still crossed it, so a highlight that follows the current price is not enough. `createAlerts` from `bo-grid/realtime` keeps that state: named rules on a row fire when they turn true, and the row stays lit until someone acknowledges it, wherever the price goes next. Each row below has a level above and below its price. When the last price crosses one, the row lights up and stays lit. Acknowledge one row with A, or all of them with the button. Example: Alerts that stay lit (alerts.svelte) ```svelte
{active.length} lit{active.length ? ':' : ''} {active.slice(0, 4).map((a) => `${a.row.symbol} ${a.rule}`).join(', ')} or focus a row and press A
void (row && alerts.ack(row.id)) }} onReady={(a) => (api = a)} />
``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` feed.ts: ```ts // A simulated market feed for the docs' examples: a random walk on each // quote's price. In an app, this is your socket's message handler. import type { Quote } from './data'; export type Tick = { price: number; change: number; volume: number }; /** Calls `onTick` about `perSecond` times a second (a number, or a function read on every beat, so the rate can change while it runs), for a random row each time. Returns a function that stops the feed. */ export function simulateFeed(rows: readonly Quote[], onTick: (id: number, tick: Tick) => void, perSecond: number | (() => number) = 20): () => void { const rate = typeof perSecond === 'function' ? perSecond : () => perSecond; const state = rows.map((r) => ({ id: r.id, ref: r.price / (1 + r.change / 100), price: r.price, volume: r.volume })); const every = 50; let due = 0; const timer = setInterval(() => { for (due += (rate() * every) / 1000; due >= 1; due--) { const s = state[Math.floor(Math.random() * state.length)]; s.price = Math.max(0.01, Math.round(s.price * (1 + (Math.random() - 0.5) * 0.006) * 100) / 100); s.volume += Math.ceil(Math.random() * 40) * 100; onTick(s.id, { price: s.price, change: Math.round(((s.price - s.ref) / s.ref) * 10_000) / 100, volume: s.volume }); } }, every); return () => clearInterval(timer); } ``` ## Creating alerts ```ts import { createAlerts } from 'bo-grid/realtime'; // Quote is your row type: { id, symbol, last, alertAbove?, alertBelow?, … } let version = $state(0); const alerts = createAlerts({ key: (q) => q.symbol, rules: { above: (q) => q.alertAbove !== undefined && q.last >= q.alertAbove, below: (q) => q.alertBelow !== undefined && q.last <= q.alertBelow, }, onChange: () => version++, // an alert fired, or was acknowledged }); // On each update, with all the quotes or only the ones that changed: for (const a of alerts.check(quotes)) chime(a.key, a.rule); ``` `check` returns the alerts that fired just now, each `{ key, rule, row, at }`: play a sound, or show a toast. With plain rows patched through `api.patchRows`, call it right after the patch, when the rows hold the new values. ## Lighting the rows The lit style is yours, through `rowClass` (or `cellClass` on the price column). Give the grid a new function whenever the lit alerts change, so the rows restyle even when no data arrives: ```svelte { if (row) alerts.ack(String(row.symbol)); } }} height={480} /> ``` The grid hands callbacks a `GridRow`; the cast says it is one of your quotes. `isLit(row, rule?)` asks about one rule or any; `lit(row)` lists a row's lit rules. A hotkey (see [Keyboard & hotkeys](https://bonguynvan.github.io/bo-grid/docs/keyboard/)) acknowledges the focused row. ## How alerts behave - **Once per crossing.** A rule fires when it turns true. While it stays lit, the price can go back and forth without a second alert. - **Acknowledging.** `ack(key, rule?)` darkens a row's alerts, or one rule's, and returns how many went dark; `ackAll()` darkens them all. A rule still holding when acknowledged waits until it stops holding before it can fire again, so a price sitting past its level does not light up again at once. - **An alert panel.** `active()` lists the lit alerts, newest first, each with its row as last checked and when it fired. `size` counts them. - **The first check** fires for a rule that is already true. With `fireOnFirst: false`, it waits for the rule to stop holding and turn true again. - **A level that moves** is a rule that now reads differently: moved past the price, it fires. A lit alert stays lit until acknowledged, wherever the level goes; acknowledge on edit if that is what your screen wants. - **Rows that leave.** `check(rows, { complete: true })` says the rows given are the whole list and forgets every key not among them, lit alerts included. `forget(key)` drops one key, and `clear()` everything. Keys are unique within one `check`. - **Errors.** A rule that throws stops the check there, after counting the alerts that fired before it. `onChange` runs whenever the lit alerts change (a fire, an acknowledgement, a forget), and `version` bumps with it. The crypto watchlist in the FX and crypto example lights a row when its price reaches its level; A acknowledges it: Live example: https://bonguynvan.github.io/bo-grid/examples/fxcrypto/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference `createAlerts(options)` returns an `Alerts`. | Option | Type | Default | What it does | | --- | --- | --- | --- | | `key` | `(row: R) => K` | required | A row's identity (its symbol). | | `rules` | `Record boolean>` | required | Named rules; each fires when it turns true. Read once, when the alerts are made. | | `fireOnFirst` | `boolean` | `true` | Whether a rule already true at a row's first check fires. | | `onChange` | `() => void` | none | Runs when the lit alerts change. | | Member | What it does | | --- | --- | | `check(rows, { complete? })` | Checks rows and returns the alerts that fired now. | | `isLit(row, rule?)` | Whether a row has a lit alert (for `rule`, or any). | | `lit(row)` | A row's lit rules, in the order the rules were given. | | `ack(key, rule?)` | Acknowledges a row's alerts; returns how many went dark. | | `ackAll()` | Acknowledges every alert. | | `active()` | The lit alerts, newest first. | | `size`, `version` | Lit alerts; a counter that bumps on every change. | | `forget(key)`, `clear()` | Drops one key, or everything. | - `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 (row.status === 'Rejected' ? 'rejected' : undefined)} /> ``` - `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 { if (row) buy(row); }, 'Shift+C': cancelAll, Delete: ({ row }) => { if (row) cancel(row); } }} /> ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/alerts/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Server-side data > Back the grid with a row source that serves only the rows in view, so the dataset can be millions of rows on a server. Instead of an array of rows, a grid can read from a **row source**: an object with one method, `getRows`, that returns a window of rows for the current sort and filter. The grid asks only for the rows in view (plus a small buffer), so the full dataset never has to be in the browser. Rows not loaded yet show as placeholders. 50,000 orders behind a source that answers after 300 ms, like a server. Scroll fast, sort a column, or search. Example: A row source (server-data.svelte) ```svelte

``` ## Writing a source `getRows` gets the range to load, the sort and the filters, and returns the rows of that range with the total row count (which sizes the scrollbar). It can return the result directly or as a promise: ```svelte ``` `rows` is required by the types; pass an empty array in source mode. | Parameter | Meaning | | --- | --- | | `range` | `{ start, end }`: row indexes to return, `end` exclusive | | `sorts` | The full sort order, primary first (may be empty) | | `sort` | The primary sort, or `null`, for sources that sort by one column | | `filter` | The grid's `filter` text | | `quickFilter` | The text in the built-in search box (`quickFilter`); a row should match both | | `columnFilters` | The column filters from the filter menu, by column key | When the sort or a filter changes, the grid drops what it has loaded and asks again, on every change of the search text too: debounce in your source if your server needs fewer requests. A slow answer to an old request is ignored once a newer one is out. ## What runs where - **Sorting and filtering** belong to the source: header sorts and the text, number and date filter menus are sent to `getRows`. The built-in search box sends its text as `quickFilter`, and your own search input can pass `filter`, as the example does. The filter row is for in-memory data. A set filter needs the column's values, so it is for in-memory data too. - **Grouping, tree data, the totals row, the fill handle and find** work on in-memory rows and are not applied in source mode. - `api.scrollToRow` and `api.focusCell` look rows up by id among in-memory rows, so in source mode they return `false`. ## An in-memory source `createArraySource(rows, options)` wraps an array in the same interface. It is handy for trying the source path, for tests, and as a client-side adapter: ```ts import { createArraySource } from 'bo-grid'; const source = createArraySource(allRows, { latency: 300, // pretend to be a server (ms) filterKeys: ['order', 'symbol'], // fields the `filter` text searches }); ``` It applies the sorts and column filters, searches `filterKeys` for the `filter` and `quickFilter` text (no `filterKeys`: no text search), and slices the range. Pass `filterTypes` to apply [custom filters](https://bonguynvan.github.io/bo-grid/docs/custom-filters/) too. ## A million rows A trade tape of a million rows, windowed from a synthetic source on demand: Live example: https://bonguynvan.github.io/bo-grid/examples/bigdata/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference - `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 fetchPage(range.start, range.end, sorts, filter) }} /> ``` - `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. - `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). --- Page: https://bonguynvan.github.io/bo-grid/docs/server-data/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Wide grids > Render only the columns in view for grids with dozens or hundreds of columns, and highlight the column under the pointer. Rows are always virtualized: only the rows in view are drawn. For grids with many columns (a price history by day, a risk matrix, a wide export), `virtualizeColumns` does the same across: only the columns in the horizontal scroll window, plus a small buffer, are drawn, so a grid of a hundred columns costs about as much as one of ten. 41 columns, of which only the visible ones render. Scroll sideways; the Symbol column stays pinned, and the column under the pointer lights up. Example: Column virtualization (wide-grids.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Turning it on ```svelte ``` - Columns get pixel widths and the grid scrolls horizontally; `flex` columns grow to fill any width left over. - Pinned columns always render, so a label column stays put on the left (or right). See [Pinned columns](https://bonguynvan.github.io/bo-grid/docs/pinned-columns/). - `colSpan` is ignored while columns are virtualized: every cell draws in its own column. Without `virtualizeColumns`, columns fit the grid's width and every column renders, which is the better choice up to a few dozen columns. ## Following a column On a wide board, `columnHover` highlights the column under the pointer, body and header, so a value is easy to follow down its column. It draws one overlay for the whole column rather than restyling each cell, and covers pinned columns too. Its colour is the theme token `colHover`. ```svelte ``` ## Sixty columns A wide board with a pinned label column: Live example: https://bonguynvan.github.io/bo-grid/examples/wide/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference - `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. - `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. - `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. - `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. - `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) } ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/wide-grids/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Theming > Style the grid with a built-in preset, a token map of your own, or CSS variables, from colours to fonts, corner radius and density. The grid brings its own styles: there is no stylesheet to import. Its look is a set of tokens (colours, fonts, radius, padding), and the `theme` prop sets them from a built-in preset or a map of your own. Every token is also a `--bo-grid-*` CSS variable, so a page stylesheet can set them too. Switch between compact, default and roomy: density and shape are tokens like the colours. Example: Density and shape tokens (theming.svelte) ```svelte

{#each Object.keys(looks) as name (name)} {/each}

``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Presets `theme` takes a name, `'dark'` (the default) or `'light'`, or a token map. Eight presets are exported as token maps: | Export | Name in `themePresets` | | --- | --- | | `darkTheme` | `dark` | | `lightTheme` | `light` | | `highContrastDark` | `high-contrast-dark` | | `highContrastLight` | `high-contrast-light` | | `midnightTheme` | `midnight` | | `terminalTheme` | `terminal` | | `tradecanvasTheme` | `tradecanvas` | | `tradecanvasLightTheme` | `tradecanvas-light` | ```svelte ``` `themePresets` maps each name to its tokens, for a theme picker; `ThemePreset` is the type of the names. The high-contrast presets are tuned well past the WCAG AA contrast ratios. Live example: https://bonguynvan.github.io/bo-grid/examples/themes/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Your own tokens A token map sets only the tokens you give; the rest keep their defaults: ```svelte ``` | Token | What it sets | | --- | --- | | `bg`, `headerBg` | Grid and header background | | `rowA`, `rowB`, `rowHover` | Alternating row backgrounds and the hovered row | | `colHover` | The column highlight under `columnHover` | | `text`, `textDim`, `border` | Text, secondary text and lines | | `up`, `down`, `amber` | Rises, falls and warnings (flashes, signed numbers, badges) | | `selFill`, `selBorder` | The selection's fill and outline | | `mono`, `sans`, `fontSize` | Number and text fonts, and the cell font size | | `radius`, `cellPad`, `headerH` | Corner radius, horizontal cell padding, header height | | `scheme` | `'light'` or `'dark'` for native controls (checkboxes, date pickers, scrollbars) | Numbers use tabular figures, so digits line up down a column whatever the font. ## Density Density is two settings: the `rowHeight` prop and the `cellPad` token (with `fontSize` to match): ```svelte ``` ## CSS variables Each token is a CSS custom property on the grid, so you can set them from a stylesheet on any ancestor instead of the prop: ```css .my-app { --bo-grid-bg: #ffffff; --bo-grid-text: #1a1a1a; --bo-grid-up: #16a34a; --bo-grid-down: #dc2626; --bo-grid-scheme: light; } ``` The variable names follow the tokens: `bg` is `--bo-grid-bg`, `headerBg` is `--bo-grid-header-bg`, `cellPad` is `--bo-grid-cell-pad`. A `theme` prop sets the same variables on the grid itself, so it wins over an ancestor's. `themeVars(theme)` turns a token map into the variable declarations, for a wrapper of your own. The `--bo-grid-*` variables are inputs: they hold a value only where you or a `theme` set one. To use a theme colour inside the grid (a custom cell, a `cellClass` style), read the colour the grid resolved, the same name without `grid-`: `var(--bo-up)`, `var(--bo-down)`, `var(--bo-text)`, `var(--bo-text-dim)`, `var(--bo-amber)`. These always hold the current theme's colour. Native controls inside the grid (checkboxes, date and number inputs, the search box, scrollbars) follow the theme through `color-scheme` and `accent-color`. A custom map is treated as dark unless it sets `scheme: 'light'`. ## Styling rows and cells For styling that depends on the data, use a column's `cellClass` or the grid's `rowClass`; see [Row styles](https://bonguynvan.github.io/bo-grid/docs/row-styles/) and [Conditional formatting](https://bonguynvan.github.io/bo-grid/docs/conditional-formatting/). ## Reference - `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 ``` - `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 (row.note ? 56 : 36)} /> ``` - `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. --- Page: https://bonguynvan.github.io/bo-grid/docs/theming/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Localization > Translate every string the grid shows and format numbers and dates for a locale, per grid. Two props localize a grid. `locale` picks how numbers, prices and dates are formatted. `labels` replaces the text the grid writes itself: menu items, filter operators, the pager, the empty message and the names read out by screen readers. Both apply per grid, so two grids on one page can use two languages. A grid in Vietnamese: `vi-VN` number formatting, translated pager and filter menu. Open a column's funnel, or page through the rows. Example: Vietnamese labels and formats (localization.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Formats `locale` is a BCP 47 tag (`'vi-VN'`, `'de-DE'`, `'ja-JP'`). It sets the default for the built-in `price`, `date`, `currency` and `relative` formatting, the selection's totals bar and the pager's row count. A column's own `locale` wins over the grid's: ```svelte ``` ```ts { type: 'currency', key: 'cost', header: 'Cost', currency: 'JPY', locale: 'ja-JP' } ``` Without `locale`, columns format in `en-US` and the grid's own counts use the browser's locale. ## Labels Pass only the strings you change; they are merged over the English defaults: ```svelte `Lọc cột ${h}`, pageOf: (p, n, total, locale) => `Trang ${p} / ${n} · ${total.toLocaleString(locale)} dòng`, }} /> ``` Labels with values in them are functions rather than strings with placeholders, so a translation can put the parts in any order. `DEFAULT_LABELS` holds every key with its English text, a good starting point for a full translation, and the `GridLabels` type lists them for your editor: ```ts import { DEFAULT_LABELS, type GridLabels } from 'bo-grid'; export const vi: Partial = { apply: 'Áp dụng', clear: 'Xóa', // … }; ``` Accessible names are labels too (`selectRow`, `columnMenu`, `resizeColumn`, `rowNumber` and others), so a translated grid also speaks the reader's language to a screen reader. ## Reference - `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. - `labels`: `Partial` (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 ``` - `emptyMessage`: `string` (default `labels.noRows`) — Message shown when there are no rows. - `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`. --- Page: https://bonguynvan.github.io/bo-grid/docs/localization/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Accessibility > Grid and treegrid semantics with true row and column counts, a full keyboard model, visible focus and WCAG 2.1 AA contrast. bo-grid follows the ARIA grid pattern and targets **WCAG 2.1 AA**. Rows are virtualized, so only a window of them is in the page at any time; the grid reports the full size and every row's real position, so a screen reader is not misled by the window. Try it with the keyboard only: Tab into the grid, move with the arrows, tick a row with Space, and open the column menu with Alt+↓ (its **Filter…** item filters the column). Example: Keyboard only (accessibility.svelte) ```svelte ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Give the grid a name Set `ariaLabel` so the grid has an accessible name ("Watchlist", "Open orders"): ```svelte ``` ## Roles and states - The grid is `role="grid"` (`role="treegrid"` with tree data), with `aria-rowcount` and `aria-colcount` giving the full size, not the rendered window, and `aria-multiselectable` while range selection is on. It holds only rows; the toolbar, pager and menus sit beside it. - Rows are `role="row"` with `aria-rowindex`; cells are `role="gridcell"` with `aria-colindex` and `aria-selected`. Headers are `role="columnheader"` with `aria-sort`. - The focused cell is announced through `aria-activedescendant`, so assistive technology follows it as it moves. - Tree rows carry `aria-level` and `aria-expanded`; the row-number column is made of row headers. - Sparkline cells have a text label; copies of rows drawn for sticky headers or while loading are `aria-hidden`; the selection's totals bar and the find bar's match count are live regions. ## Keyboard The grid is a single tab stop with arrow-key navigation inside it. Everything works from the keyboard: editing, selection, copy and paste, the column menu (Alt+↓), the row menu (Shift+F10), and filtering through the column menu's **Filter…** item. Menus move focus in when they open and give it back when they close, and there are no keyboard traps. The full map is in [Keyboard & hotkeys](https://bonguynvan.github.io/bo-grid/docs/keyboard/). ## Focus, contrast and motion - Every control that takes focus shows a visible `:focus-visible` ring: headers, menu items, the pager, filter and column panels, tree and detail toggles. - The built-in themes meet AA contrast for text and for interface parts; the high-contrast presets go well beyond it. See [Theming](https://bonguynvan.github.io/bo-grid/docs/theming/). - Flashes and other animations respect `prefers-reduced-motion`. - Every string the grid writes, accessible names included, can be translated; see [Localization](https://bonguynvan.github.io/bo-grid/docs/localization/). The full audit (keyboard map, roles, measured contrast ratios and conformance notes) is in [ACCESSIBILITY.md](https://github.com/bonguynvan/bo-grid/blob/main/ACCESSIBILITY.md). ## Reference - `ariaLabel`: `string` (default `undefined`) — Accessible name for the grid (`aria-label` on the `role="grid"` root). - `labels`: `Partial` (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 ``` - `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 { if (row) buy(row); }, 'Shift+C': cancelAll, Delete: ({ row }) => { if (row) cancel(row); } }} /> ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/accessibility/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Export & import > Export rows to CSV or Excel, print every row, and turn CSV, TSV or JSON text back into rows. Exports work from your rows and columns, not from what happens to be drawn, so they include every row, even those scrolled out of view. CSV import and export need no dependencies; Excel export loads its writer only when it runs. Download the rows as CSV, or open a print view of every row. The text under the grid is the CSV string itself. Example: CSV and print (export.svelte) ```svelte

{preview}
``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## CSV ```ts import { exportCSV, toCSV } from 'bo-grid'; exportCSV('quotes.csv', rows, columns); // downloads a file const text = toCSV(rows, columns, { formatted: true }); // or returns the string ``` Both take `{ formatted, header }`: - By default numbers export as raw numbers, so a spreadsheet can compute on them; `formatted: true` exports the text the cells show instead. Dates always export as text. - `header: false` leaves out the header row. - Sparkline columns are skipped. To export what the reader is looking at, after their filters and sort, use the grid's `api.exportCSV(filename?)`; see [Grid API](https://bonguynvan.github.io/bo-grid/docs/grid-api/). ## Excel `exportXLSX` writes an `.xlsx` file. It loads the optional `xlsx` package with a dynamic import, so it lands in its own chunk and costs nothing until it runs; install `xlsx` only if you use it: ```ts import { exportXLSX } from 'bo-grid'; await exportXLSX('quotes.xlsx', rows, columns); // npm i xlsx ``` It takes the same options as CSV. ## Printing The grid draws only the rows in view, so printing the page would leave the rest out. `printTable(rows, columns, { title })` opens a print window with every row as a plain table (the browser's dialog can save it as a PDF), and `toHTMLTable(rows, columns)` returns that table as an HTML string to put in a page or an email. Values are escaped. ```ts import { printTable } from 'bo-grid'; printTable(rows, columns, { title: 'Quotes' }); ``` Live example: https://bonguynvan.github.io/bo-grid/examples/print/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Importing CSV, TSV and JSON `parseCSV(text, columns)` reads CSV into rows ready for the grid: it handles quoted fields (with commas, quotes and line breaks inside), matches the header row to your columns, converts number and date columns, and gives each row an `id`. `parseTSV` reads tab-separated text, which is what spreadsheets and the grid's own copy put on the clipboard. ```ts import { parseCSV, toCSV } from 'bo-grid'; const text = toCSV(rows, columns); const back = parseCSV(text, columns); // a round trip ``` For JSON, `parseJSON(text)` reads an array of objects, and `rowsFromObjects(objects)` converts objects you already have: ```ts import { rowsFromObjects } from 'bo-grid'; const rows = rowsFromObjects(await (await fetch('/api/positions')).json()); ``` `parseRows(text, columns?)` detects JSON, TSV or CSV by itself, which suits a paste handler: ```svelte
(rows = parseRows(e.clipboardData?.getData('text') ?? '', columns))}>Paste a table here
``` `parseCSVMatrix(text)` returns the raw cells as a string matrix, for files that need their own mapping. Live example: https://bonguynvan.github.io/bo-grid/examples/csv/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/) ## Reference - `exportCSV(filename?: string): Promise` — 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`); ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/export/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Grid API > The grid's handle for actions that are not state, such as scrolling to a row, focusing a cell, saving a layout or patching live rows. Most of the grid is driven by props: rows, columns, sort, filters. Some things are actions rather than state, with no resting value a prop could hold: scroll to this row, focus that cell, download the current view, save the layout. For those the grid hands you one object, the grid API, through `onReady`. Each button calls one API method. Resize or move a column, then save the layout, change it again and restore it. Example: Calling the grid API (grid-api.svelte) ```svelte

(api = a)} /> ``` data.ts: ```ts // Small, fixed data for the docs' examples: every example reads the same // symbols, so a reader compares features, not datasets. // A type, not an interface: it then fits GridRow's index signature. export type Quote = { id: number; symbol: string; name: string; sector: string; price: number; change: number; volume: number; }; const BASE: Omit[] = [ { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 }, { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 }, { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 }, { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 }, { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 }, { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 }, { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 }, { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 }, { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 }, { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 }, { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 }, { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 }, ]; /** A fresh copy of the quotes, so one example's edits never reach another. */ export function quotes(): Quote[] { return BASE.map((q, i) => ({ id: i + 1, ...q })); } ``` ## Getting the handle `onReady` is called once, when the grid mounts, with the API object: ```svelte (api = a)} /> ``` In React and Vue the same object is the component's ref (`ref.current` in React, the template ref's `api` in Vue); see [React, Vue & more](https://bonguynvan.github.io/bo-grid/docs/frameworks/). ## Methods | Method | What it does | | --- | --- | | `scrollToRow(id, align?)` | Scroll a row into view; `align` is `'nearest'` (default), `'start'`, `'center'` or `'end'` | | `focusCell(rowId, columnKey)` | Focus and select one cell | | `getFocusedCell()` | The focused `{ row, column }`, or `null` | | `getSelectedRows()` | The ticked rows (with `rowSelection`), in view order | | `autosizeColumns(keys?)` | Fit columns to their content, as the column menu's **Autosize** does | | `exportCSV(filename?)` | Download the current view, after filters and sort, as CSV | | `getState()` / `applyState(state)` | Save and restore the user's layout (below) | | `patchRows(patches)` | Write live values into rows in place; see [Live updates](https://bonguynvan.github.io/bo-grid/docs/realtime/) | | `refresh()` | Re-run sort and filters against values changed in place | | `flashCells(options?)` | Flash cells on demand; see [Cell flashing](https://bonguynvan.github.io/bo-grid/docs/flashing/) | | `pinRow(id, pinned?)` / `getPinnedRowIds()` | Pin rows above the scroll from code | | `openFind(query?)` | Open the find bar (with `findBar`) | Methods that look a row up by id (`scrollToRow`, `focusCell`) return `false` when the row is not in the current view: filtered out, on another page, or in a grid backed by a server `source`. ## Saving a layout `getState()` returns the user's layout as one plain, JSON-safe object: column order, widths, hidden columns, pinned columns, pinned rows, sorts and filters. Keep it wherever you keep user settings, per user or per workspace, and give it back with `applyState(saved)`: ```ts const saved = api.getState(); localStorage.setItem('orders-layout', JSON.stringify(saved)); // later api.applyState(JSON.parse(localStorage.getItem('orders-layout') ?? 'null')); ``` `applyState` fits the saved layout onto the columns as they are now: - Entries for columns that no longer exist are dropped. - A new column lands where it is declared, after the nearest column declared before it. - Malformed fields are ignored rather than thrown on. - A state from another `version` of the format is refused whole (`applyState` returns `false`), because half a layout is worse than none. The same rules are exported as `reconcileState(saved, columnKeys)`, for a server that wants to apply them itself. For the simple case, `persistKey` saves the layout in the browser's local storage with no code. ## Also exported Besides `Grid` and its types, the package exports helpers you can use on their own: | Group | Exports | | --- | --- | | Components | `Grid`, `Sparkline` | | Formatting | `fmtPrice`, `fmtPercent`, `fmtVolume`, `fmtDate`, `fmtCurrency`, `relativeTime` | | Data | `aggregate`, `pivot`, `sortRows`, `heatColor`, `resolveColumns`, `buildMergePlan`, `colSpanRow` | | Export and import | `toCSV`, `exportCSV`, `exportXLSX`, `parseCSV`, `parseCSVMatrix`, `parseTSV`, `parseJSON`, `parseRows`, `rowsFromObjects`, `toHTMLTable`, `printTable`, `escapeHTML` | | Server data | `createArraySource` | | Theming and text | `themeVars`, `themePresets` and the preset token maps, `DEFAULT_LABELS`, `resolveLabels` | | Layout state | `reconcileState`, `GRID_STATE_VERSION` | | Canvas | `drawCandles`, `setupHiDpiCanvas` (draw candlesticks on a canvas of your own) | Realtime and trading helpers live in their own entries, `bo-grid/realtime`, `bo-grid/trading` and `bo-grid/ladder`. Every prop and method, with its type, is in the [Props reference](https://bonguynvan.github.io/bo-grid/docs/reference/). ## Reference - `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 (api = a)} /> ``` - `persistKey`: `string` (default `undefined`) — Remember the user's layout (column order, widths, hidden and pinned columns, pinned rows) in localStorage under this key. - `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` — 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]>): 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'); ``` --- Page: https://bonguynvan.github.io/bo-grid/docs/grid-api/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # Error codes > Every error bo-grid throws has a stable code, says what went wrong with the value it got, gives the exact fix, and links here. An error from bo-grid reads like this: ```text BG2004: TradeTape: capacity must be a number of 1 or more, got 0. Fix: Pass how many trades to keep, e.g. new TradeTape(500). See https://bonguynvan.github.io/bo-grid/docs/errors/#bg2004 ``` - **The code** (`BG2004`) never changes meaning, so you can search for it, test for it, and match it in error reporting. It is also on the error as `error.code`. - **The message** says what happened, with the value the grid got. - **The fix** says exactly what to change. - **The link** opens the code's entry on this page. The error is still an `Error`, `TypeError`, `RangeError` or `SyntaxError`, so existing `catch` blocks keep working. Its type is exported as `BoGridError`: ```ts import type { BoGridError } from 'bo-grid'; import { createPositions, type PositionFill } from 'bo-grid/trading'; const positions = createPositions(); function record(fill: PositionFill) { try { positions.fill(fill); } catch (e) { if ((e as BoGridError).code === 'BG3003') showQtyHint(); else throw e; } } ``` A coding agent that hits one of these errors can follow the fix as written, without reading the library's source. ## Grid ### BG1001 **`hotkeys`: a binding has no key**, as in `'Ctrl+'`. End the binding with a key: `'Ctrl+B'`, `'Delete'` or `'+'`. See [Keyboard & hotkeys](https://bonguynvan.github.io/bo-grid/docs/keyboard/). ### BG1002 **`hotkeys`: an unknown modifier**, as in `'Ctl+B'`. Use `Shift`, `Alt`, `Ctrl`, `Meta` or `Mod` (Ctrl or ⌘, whichever is pressed), joined with `+`. ### BG1003 **`hotkeys`: a Shift binding that cannot fire**, as in `'Shift+1'`. A key event reports the character Shift types, not the key under it, so bind that character: `'!'` for Shift+1, `'?'` for Shift+/. ### BG1004 **`hotkeys`: a key bound twice**, as in `{ b: …, B: … }`, or `Mod+K` next to `Ctrl+K` (`Mod+K` binds both Ctrl+K and Meta+K). Give each key one binding. ### BG1101 **`parseJSON`: the text is not a JSON array of objects.** Pass text like `[{"id":1,"sym":"AAA"}]`; wrap a single object in `[ ]`. See [Export & import](https://bonguynvan.github.io/bo-grid/docs/export/). ### BG1102 **`exportXLSX`: the optional peer dependency `xlsx` is not installed.** Install it next to bo-grid: `npm i xlsx`. The core never loads it until you export. ### BG1103 **`parseJSON`: the text is not valid JSON.** The message includes the parser's reason. Pass the JSON text as received; for CSV or TSV text use `parseCSV` or `parseTSV`, or `parseRows` to detect the format. A `SyntaxError`, as before. ### BG1201 **Sparkline: no 2d canvas context.** Sparklines draw on a canvas, which a test environment such as jsdom does not have. Draw them in a browser; in tests, stub `HTMLCanvasElement.prototype.getContext`. ## Realtime ### BG2001 **`serveFeed`: a feed with that name already runs in this worker.** Give each feed in a worker its own `name`, or close the first one. See [Worker feeds](https://bonguynvan.github.io/bo-grid/docs/worker-feed/). ### BG2002 **`createOrderBook`: `maxLevels` is not a whole number above 0.** Pass a whole number, such as `maxLevels: 20`, or leave it out to keep every level. See [Order book](https://bonguynvan.github.io/bo-grid/docs/order-book/). ### BG2003 **`createLinger`: `ms` is not a finite number of 0 or more.** Pass how long a row lingers, in milliseconds, such as `ms: 3000` (`0` removes it at once). See [Orders & positions](https://bonguynvan.github.io/bo-grid/docs/orders-positions/). ### BG2004 **`TradeTape`: the capacity is below 1 or not a finite number.** Pass how many trades to keep, such as `new TradeTape(500)`; a fraction is rounded down. See [Depth ladder](https://bonguynvan.github.io/bo-grid/docs/depth-ladder/). ## Trading ### BG3001 **`createPositions`: a fill's `symbol` is empty or not a string.** Pass the instrument's symbol: `{ symbol: 'VNM', side: 'buy', qty: 100, price: 68.5 }`. See [Orders & positions](https://bonguynvan.github.io/bo-grid/docs/orders-positions/). ### BG3002 **`createPositions`: a fill's `side` is not `'buy'` or `'sell'`.** Pass `side: 'buy'` or `side: 'sell'`, in lower case. ### BG3003 **`createPositions`: a fill's `qty` is not a number above 0.** Pass the filled quantity as a positive number; the side says which way (`side: 'sell'`, not a negative `qty`). ### BG3004 **`createPositions`: a fill's `price` is not a finite number.** Pass the price as a number, such as `price: 68.5`; parse strings first. ### BG3005 **`createPositions`: a fill's `fee` is not a finite number.** Pass the fee as a number, such as `fee: 1.2`, or leave it out. --- Page: https://bonguynvan.github.io/bo-grid/docs/errors/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt # 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 ``` - `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 (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 ``` - `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) => void` — Called with the selected row ids whenever the row-selection set changes. ```svelte (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 ``` - `onColumnVisibilityChange`: `(hidden: string[]) => void` — Called with all currently-hidden column keys whenever the runtime set changes (column menu hide/show). ```svelte 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 (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 `${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 (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 { 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 (sort = next)} /> ``` - `onSortChange`: `(sort: SortState[]) => void` — Called with the new sort order whenever a header is clicked. ```svelte save('sort', sort)} /> ``` - `columnFilters`: `Record` (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 ``` - `onFilterChange`: `(filters: Record) => void` — Called with the full column-filter map whenever a header filter changes. ```svelte (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 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 ``` - `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` (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 ``` - `defaultColumn`: `DefaultColumn` (default `undefined`) — Settings applied under every column — its own fields (and a registered `cellType`) win. ```svelte ``` - `cellTypes`: `Record` (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 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 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 (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 ``` - `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 ``` - `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 ``` - `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 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 [{ 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 {#snippet detail({ row })}

{row.sym}: {row.note}

{/snippet}
``` - `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 row.children as GridRow[] | undefined} /> ``` - `loadChildren`: `(row: GridRow) => Promise` — 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 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 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 fetch(`/api/board/${key}`).then((r) => r.json())} /> ``` - `loadGroup`: `(key: string) => Promise` — Load a lazy group's leaf rows on expand (returns a promise; shows a loading row, then caches). Required with `lazyGroups`. ```svelte 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 { 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 ``` - `onPageSizeChange`: `(size: number) => void` — Called with the new page size when the pager's size dropdown changes. ```svelte (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 (page = p)} /> ``` - `onColumnReorder`: `(keys: string[]) => void` — Called with the new column-key order after a header drag-reorder. ```svelte 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 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 ``` - `aggregations`: `AggKind[]` (default `['sum', 'avg', 'count', 'min', 'max']`) — Which stats the selection footer shows over a multi-cell range. ```svelte ``` - `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 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 (row[column.key] = value)} /> ``` - `cell`: `Snippet<[{ row: GridRow; column: ColumnDef; value: unknown }]>` — Render content for `type: 'custom'` columns. ```svelte {#snippet cell({ row, column })}{#if column.key === 'chart'}{/if}{/snippet} ``` ## 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` — 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 `