@svgrid/mcp 3.0.3 → 3.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/data.js CHANGED
@@ -3639,7 +3639,7 @@ export const docs = [
3639
3639
  "path": "docs/enterprise/studio/cli.md",
3640
3640
  "title": "The Studio CLI",
3641
3641
  "section": "Studio",
3642
- "markdown": "# The Studio CLI\n\n`@svgrid/studio` is the command-line generator. One command reads a schema or a\nlive database and writes a working CRUD screen into your SvelteKit app.\n\n![The Studio CLI reads a schema file or a live database, runs one generate command, and writes a working CRUD screen: a schema module, a +server.ts route, and a +page.svelte into your SvelteKit app.](/docs-media/studio-cli.svg)\n\n> **New here?** Start with [Getting started](./getting-started.md) for the full\n> step-by-step path (create an app, install, first screen). This page is the CLI\n> reference.\n\n```bash\nnpx @svgrid/studio add <name> [--from <schema> | --db <dialect> --url <conn>] [options]\n```\n\n## `init`\n\nThe guided path: answer a few questions and get a working app. This is also what\nruns when you type `npx @svgrid/studio` with no arguments in a terminal.\n\n```bash\nnpx @svgrid/studio init [--db <dialect> --url <conn>] [--dataset <id>] [--out <dir>]\n```\n\nIt asks where the data comes from, which tables to include, which pages each\ntable gets, and which theme to use - then writes a runnable SvelteKit app plus a\n`studio.config.json` you can reopen in the [designer](./app-designer.md).\n\nEvery table you pick gets a **list** screen (searchable grid with status pills\nand totals), a **manage** screen (grid + editable record panel), and - when other\ntables relate to it - a **record page** with a tab per related collection. The\napp leads with an overview dashboard over its most-referenced table.\n\n| Flag | Description |\n| --- | --- |\n| `--db <dialect>` | Skip the source question and read a live database: `postgres` \\| `supabase` \\| `mysql` \\| `mssql` \\| `sqlite`. The driver is installed for you. |\n| `--url <conn>` | Connection string (or file path for SQLite) for `--db`. |\n| `--supabase-url <url>` | Read a Supabase project over its REST API instead - no driver, no connection string. |\n| `--supabase-key <anon>` | The project's anon key, paired with `--supabase-url`. |\n| `--dataset <id>` | Start from sample data: `customers-orders`, `products-categories`, `projects-tasks`, `employees-departments`, `tickets-accounts`. |\n| `--title <name>` | App name. |\n| `--out <dir>` | Folder to write the app into (default: `.`). |\n| `--theme <id>` | Design-system preset (see [Theming](./theming.md)). |\n| `--dark` | Start in dark mode. |\n| `-y`, `--yes` | Take every default and ask nothing - useful in scripts and CI. |\n\n```bash\n# guided, from your own database\nnpx @svgrid/studio init --db postgres --url $DATABASE_URL --out my-app\n\n# from Supabase, over the REST API (no driver to install)\nnpx @svgrid/studio init --supabase-url https://xxxx.supabase.co --supabase-key $SUPABASE_ANON_KEY\n\n# no questions at all: a seeded sample app\nnpx @svgrid/studio init --yes --dataset projects-tasks --out demo\n```\n\nThe designer's **New app** button runs the same flow visually, and produces the\nsame project - pick whichever suits you.\n\n## `designer`\n\nOpen the visual [app designer](./app-designer.md) in your browser, auto-saving\nto `studio.config.json` and generating the app to a folder. See\n[Launch the designer](./launch.md) for the full guide.\n\n```bash\nnpx @svgrid/studio designer [--config <path>] [--out <dir>] [--port <n>] [--no-open]\n```\n\n## `add`\n\nGenerates three files for an entity: the schema module, the API route, and the\npage.\n\n| Flag | Description |\n| --- | --- |\n| `--from <path>` | Introspect a schema file: a Drizzle `schema.ts` or a Prisma `schema.prisma` (auto-detected). |\n| `--db <dialect>` | Connect to a live database: `postgres` \\| `supabase` \\| `mysql` \\| `mssql` \\| `sqlite`. |\n| `--url <conn>` | Connection string (or file path for SQLite) for `--db`. |\n| `--all` | Scaffold a screen for **every** table/model - works with `--from` or `--db`. |\n| `--table <name>` | Which table/model to use (defaults to `<name>`). |\n| `--sql` | Emit a `createSqlDataSource` with an `execute()` stub instead of a live driver. |\n| `--route <seg>` | Route segment (default: `<name>` / table name). |\n| `--api <path>` | API route path (default: `/api/<route>`). |\n| `--theme <id>` | Design-system preset for the generated screens (default: `ember`, the demo theme). The tokens go in the page's `<svelte:head>` inside `@layer svgrid-studio`, so an app that already defines its own `--sg-*` tokens keeps its look. |\n| `--dark` | Start in dark mode. |\n| `-h`, `--help` | Show help. |\n\n## Examples\n\n```bash\n# from a live database\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\n\n# every table in the database\nnpx @svgrid/studio add --all --db mysql --url \"$DATABASE_URL\"\n\n# from a Drizzle schema file, a specific table, custom route\nnpx @svgrid/studio add orders --from src/lib/db/schema.ts --table orders --route sales/orders\n\n# every model in a Prisma schema (relations become lookups)\nnpx @svgrid/studio add --all --from prisma/schema.prisma\n\n# SQLite file\nnpx @svgrid/studio add todos --db sqlite --url ./data.db\n```\n\n## What it writes\n\nFor `add customers`:\n\n```\nsrc/lib/customers.schema.ts # EntitySchema + row type\nsrc/routes/api/customers/+server.ts # API route (createKitHandlers + a data source)\nsrc/routes/customers/+page.svelte # the screen: grid + edit panel\n```\n\nThen `npm run dev` and open `/customers`.\n\n## A whole app with `--all`\n\n`--all` scaffolds **every** table/model, plus an app shell that ties them\ntogether. It works from a live database (`--db`) **or** a schema file (`--from`\na Drizzle `schema.ts` or Prisma `schema.prisma`):\n\n```\nsrc/routes/+layout.svelte # nav sidebar linking every entity screen\nsrc/routes/+page.svelte # home page: a card per entity\nsrc/routes/<entity>/... # one screen per table/model (as above)\n```\n\nForeign keys are resolved across the whole set - a Drizzle `.references()` or a\nPrisma `@relation` becomes a searchable lookup - so each relation points at the\nright related screen and every `/api/<entity>` route it needs exists. The same\noutput is available programmatically as\n[`scaffoldApp(schemas, options)`](./code-generation.md) from `@svgrid/enterprise/studio`,\nfed by `introspectDrizzleAll` / `introspectPrismaAll`.\n\n## The other commands\n\n`designer` and `add` are the daily drivers; the binary has four more:\n\n| Command | What it does |\n| --- | --- |\n| `svgrid-studio dev` | The designer and the **running generated app side by side** - the designer writes real files into the app folder and Vite hot-reloads them. `--app-port <n>` sets the app's port. |\n| `svgrid-studio openapi <file\\|url>` | Import an OpenAPI (JSON) spec: paths + schemas become entities with REST sources in `studio.config.json`. See [REST & custom APIs](./rest-api.md). |\n| `svgrid-studio eject [--fragment]` | Write the full app (or, with `--fragment`, a drop-in set of files for an existing app) from `studio.config.json` without opening the designer. |\n| `svgrid-studio deploy [--target <p>] [--dry-run]` | Build, then deploy through the provider's own CLI. Target: `vercel` \\| `netlify` \\| `cloudflare` \\| `node`, resolved from the flag, the config, or `svelte.config.js`. `--dry-run` prints the commands. |\n\nUseful `designer` flags beyond the basics: `--template crm|ecommerce|projects|support`\nopens a [sample app](./samples.md) directly, and `--ai` enables the built-in\ncopilot (reads `ANTHROPIC_API_KEY` from the environment).\n\n## Safe regeneration\n\nEvery generated file wraps its body in `svgrid:managed` markers. Re-running\n`add` replaces **only** the managed region and preserves everything you wrote\noutside it - so you can regenerate after a schema change without losing\ncustomizations. See [Code generation](./code-generation.md).\n\n## Verification\n\nAfter writing files, run your project's own check to confirm they compile:\n\n```bash\nnpx svelte-check\n```\n\n## Requirements\n\n`@svgrid/grid` and `@svgrid/enterprise` in your project, plus the driver for your\ndatabase (`pg` / `mysql2` / `mssql` / `better-sqlite3`) when using `--db`. Studio\nis part of the [Enterprise license](../licensing.md), with its 30-day\nevaluation period.\n\n## See also\n\n- [Databases](./databases.md) · [Drizzle schema](./drizzle.md)\n- [AI generation](./ai-generation.md) - the same engine, driven by an AI agent\n- [Visual app designer](./app-designer.md) - the same engine, driven by a UI\n"
3642
+ "markdown": "# The Studio CLI\n\n`@svgrid/studio` is the command-line generator. One command reads a schema or a\nlive database and writes a working CRUD screen into your SvelteKit app.\n\n![The Studio CLI reads a schema file or a live database, runs one generate command, and writes a working CRUD screen: a schema module, a +server.ts route, and a +page.svelte into your SvelteKit app.](/docs-media/studio-cli.svg)\n\n> **New here?** Start with [Getting started](./getting-started.md) for the full\n> step-by-step path (create an app, install, first screen). This page is the CLI\n> reference.\n\n```bash\nnpx @svgrid/studio add <name> [--from <schema> | --db <dialect> --url <conn>] [options]\n```\n\n## `init`\n\nThe guided path: answer a few questions and get a working app. This is also what\nruns when you type `npx @svgrid/studio` with no arguments in a terminal.\n\n```bash\nnpx @svgrid/studio init [--db <dialect> --url <conn> | --from <schema> | --dataset <id>] [--out <dir>]\n```\n\nIt asks where the data comes from, which tables to include, which pages each\ntable gets, and which theme to use - then writes a runnable SvelteKit app plus a\n`studio.config.json` you can reopen in the [designer](./app-designer.md).\n\nEvery table you pick gets a **list** screen (searchable grid with status pills\nand totals), a **manage** screen (grid + editable record panel), and - when other\ntables relate to it - a **record page** with a tab per related collection. The\napp leads with an overview dashboard over its most-referenced table.\n\n| Flag | Description |\n| --- | --- |\n| `--db <dialect>` | Skip the source question and read a live database: `postgres` \\| `supabase` \\| `mysql` \\| `mssql` \\| `sqlite`. The driver is installed for you. |\n| `--url <conn>` | Connection string (or file path for SQLite) for `--db`. |\n| `--from <path>` | Build from a Drizzle `schema.ts` or a Prisma `schema.prisma`: its tables, columns and foreign keys, served from memory with sample rows. |\n| `--supabase-url <url>` | Read a Supabase project over its REST API instead - no driver, no connection string. |\n| `--supabase-key <anon>` | The project's anon key, paired with `--supabase-url`. |\n| `--dataset <id>` | Start from sample data: `customers-orders`, `products-categories`, `projects-tasks`, `employees-departments`, `tickets-accounts`. |\n| `--title <name>` | App name. |\n| `--out <dir>` | Folder to write the app into (default: `.`). |\n| `--theme <id>` | Design-system preset (see [Theming](./theming.md)). |\n| `--dark` | Start in dark mode. |\n| `-y`, `--yes` | Take every default and ask nothing - useful in scripts and CI. |\n\n```bash\n# guided, from your own database\nnpx @svgrid/studio init --db postgres --url $DATABASE_URL --out my-app\n\n# from Supabase, over the REST API (no driver to install)\nnpx @svgrid/studio init --supabase-url https://xxxx.supabase.co --supabase-key $SUPABASE_ANON_KEY\n\n# no questions at all: a seeded sample app\nnpx @svgrid/studio init --yes --dataset projects-tasks --out demo\n```\n\nThe designer's **New app** button runs the same flow visually, and produces the\nsame project - pick whichever suits you.\n\n## `designer`\n\nOpen the visual [app designer](./app-designer.md) in your browser, auto-saving\nto `studio.config.json` and generating the app to a folder. See\n[Launch the designer](./launch.md) for the full guide.\n\n```bash\nnpx @svgrid/studio designer [--config <path>] [--out <dir>] [--port <n>] [--no-open]\n```\n\n## `add`\n\nGenerates three files for an entity: the schema module, the API route, and the\npage.\n\n| Flag | Description |\n| --- | --- |\n| `--from <path>` | Introspect a schema file: a Drizzle `schema.ts` or a Prisma `schema.prisma` (auto-detected). |\n| `--db <dialect>` | Connect to a live database: `postgres` \\| `supabase` \\| `mysql` \\| `mssql` \\| `sqlite`. |\n| `--url <conn>` | Connection string (or file path for SQLite) for `--db`. |\n| `--all` | Scaffold a screen for **every** table/model - works with `--from` or `--db`. |\n| `--table <name>` | Which table/model to use (defaults to `<name>`). |\n| `--sql` | Emit a `createSqlDataSource` with an `execute()` stub instead of a live driver. |\n| `--route <seg>` | Route segment (default: `<name>` / table name). |\n| `--api <path>` | API route path (default: `/api/<route>`). |\n| `--theme <id>` | Design-system preset for the generated screens (default: `ember`, the demo theme). The tokens go in the page's `<svelte:head>` inside `@layer svgrid-studio`, so an app that already defines its own `--sg-*` tokens keeps its look. |\n| `--dark` | Start in dark mode. |\n| `--title <name>` | With `--all`: the app title. Defaults to the title in `studio.config.json`, then to the one in the current layout. |\n| `-h`, `--help` | Show help. |\n\n`add --all` rewrites `src/routes/+layout.svelte` and the home page. In an app\n`init` generated, the new layout keeps the `app.css` / `custom.css` imports, the\napp title, and the theme from `studio.config.json`.\n\n## Examples\n\n```bash\n# from a live database\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\n\n# every table in the database\nnpx @svgrid/studio add --all --db mysql --url \"$DATABASE_URL\"\n\n# from a Drizzle schema file, a specific table, custom route\nnpx @svgrid/studio add orders --from src/lib/db/schema.ts --table orders --route sales/orders\n\n# every model in a Prisma schema (relations become lookups)\nnpx @svgrid/studio add --all --from prisma/schema.prisma\n\n# SQLite file\nnpx @svgrid/studio add todos --db sqlite --url ./data.db\n```\n\n## What it writes\n\nFor `add customers`:\n\n```\nsrc/lib/customers.schema.ts # EntitySchema + row type\nsrc/routes/api/customers/+server.ts # API route (createKitHandlers + a data source)\nsrc/routes/customers/+page.svelte # the screen: grid + edit panel\n```\n\nThen `npm run dev` and open `/customers`.\n\n## A whole app with `--all`\n\n`--all` scaffolds **every** table/model, plus an app shell that ties them\ntogether. It works from a live database (`--db`) **or** a schema file (`--from`\na Drizzle `schema.ts` or Prisma `schema.prisma`):\n\n```\nsrc/routes/+layout.svelte # nav sidebar linking every entity screen\nsrc/routes/+page.svelte # home page: a card per entity\nsrc/routes/<entity>/... # one screen per table/model (as above)\n```\n\nForeign keys are resolved across the whole set - a Drizzle `.references()` or a\nPrisma `@relation` becomes a searchable lookup - so each relation points at the\nright related screen and every `/api/<entity>` route it needs exists. The same\noutput is available programmatically as\n[`scaffoldApp(schemas, options)`](./code-generation.md) from `@svgrid/enterprise/studio`,\nfed by `introspectDrizzleAll` / `introspectPrismaAll`.\n\n## The other commands\n\n`designer` and `add` are the daily drivers; the binary has four more:\n\n| Command | What it does |\n| --- | --- |\n| `svgrid-studio dev` | The designer and the **running generated app side by side** - the designer writes real files into the app folder and Vite hot-reloads them. `--app-port <n>` sets the app's port. |\n| `svgrid-studio openapi <file\\|url>` | Import an OpenAPI (JSON) spec: paths + schemas become entities with REST sources in `studio.config.json`. See [REST & custom APIs](./rest-api.md). |\n| `svgrid-studio eject [--fragment]` | Write the full app (or, with `--fragment`, a drop-in set of files for an existing app) from `studio.config.json` without opening the designer. |\n| `svgrid-studio deploy [--target <p>] [--dry-run]` | Build, then deploy through the provider's own CLI. Target: `vercel` \\| `netlify` \\| `cloudflare` \\| `node`, resolved from the flag, the config, or `svelte.config.js`. `--dry-run` prints the commands. |\n\nUseful `designer` flags beyond the basics: `--template crm|ecommerce|projects|support`\nopens a [sample app](./samples.md) directly, and `--ai` enables the built-in\ncopilot (reads `ANTHROPIC_API_KEY` from the environment).\n\n## Safe regeneration\n\nEvery generated file wraps its body in `svgrid:managed` markers. Re-running\n`add` replaces **only** the managed region and preserves everything you wrote\noutside it - so you can regenerate after a schema change without losing\ncustomizations. See [Code generation](./code-generation.md).\n\n## Verification\n\nAfter writing files, run your project's own check to confirm they compile:\n\n```bash\nnpx svelte-check\n```\n\n## Requirements\n\n`@svgrid/grid` and `@svgrid/enterprise` in your project, plus the driver for your\ndatabase (`pg` / `mysql2` / `mssql` / `better-sqlite3`) when using `--db`. Studio\nis part of the [Enterprise license](../licensing.md), with its 30-day\nevaluation period.\n\n## See also\n\n- [Databases](./databases.md) · [Drizzle schema](./drizzle.md)\n- [AI generation](./ai-generation.md) - the same engine, driven by an AI agent\n- [Visual app designer](./app-designer.md) - the same engine, driven by a UI\n"
3643
3643
  },
3644
3644
  {
3645
3645
  "slug": "enterprise/studio/code-behind",
@@ -3723,7 +3723,7 @@ export const docs = [
3723
3723
  "path": "docs/enterprise/studio/getting-started.md",
3724
3724
  "title": "Getting started",
3725
3725
  "section": "Studio",
3726
- "markdown": "# Getting started\n\nThis is the gentlest path into SvGrid Studio. By the end you will have a real,\nworking **Customers** screen - a grid you can sort, filter, and page, with a\ncreate / edit form and delete - running on your machine. No prior experience\nwith SvGrid is assumed.\n\nIf you would rather just click around first, open a live demo - no install\nneeded:\n\n- **[Studio live SQL](https://svgrid.com/demos/193-studio-live-sql/)** - the whole stack in the browser, backed by real Postgres via PGlite\n- **[Supabase](https://svgrid.com/demos/194-studio-supabase/)** - connect your own hosted Postgres\n- **[The designer itself](https://svgrid.com/studio)** - open a sample app and edit it, nothing to install\n\n**Choose your tutorial.** This page needs no database and teaches the whole\nshape; the one-page tutorials build the same screen against a real backend.\nThey all end in the same place, so pick by what you have:\n\n| You have | Follow | What it adds |\n| --- | --- | --- |\n| Nothing yet | this page | the full path: install, schema, generate, run, change |\n| A Postgres connection string | [Postgres CRUD grid](./postgres-grid.md) | a server route querying your database |\n| A Supabase project | [Supabase CRUD grid](./supabase-grid.md) | browser client, keys, Row-Level Security |\n| An HTTP / JSON API | [REST CRUD grid](./rest-grid.md) | the REST adapter, no server route at all |\n| An afternoon | [Build a CRM](./tutorial-crm.md) | multi-entity: relations, master-detail, a real DB |\n\n---\n\n## Guided path - answer a few questions, get the app\n\nIf you already know where your data lives, let Studio ask:\n\n```bash\nnpx @svgrid/studio init\n```\n\nIt asks four things - where the data comes from (sample data, your database,\nin-browser Postgres, or a REST API), which tables you want, which pages each\ntable gets, and what it should look like - then writes a runnable SvelteKit app\nwith a list, an edit form and a record page per table, plus an overview\ndashboard.\n\nPointing it at a real database is one line, and Studio installs the driver for\nyou:\n\n```bash\nnpx @svgrid/studio init --db postgres --url $DATABASE_URL --out my-app\n```\n\nOn Supabase there is no driver to install at all - it is read over the project's\nREST API:\n\n```bash\nnpx @svgrid/studio init --supabase-url https://xxxx.supabase.co --supabase-key $SUPABASE_ANON_KEY\n```\n\nPrefer clicking? The visual designer has the same wizard behind its **New app**\nbutton - or open [svgrid.com/studio/new](https://svgrid.com/studio/new) to start\none in the browser. Both paths run the same generator, so they produce the same\napp. See [The Studio CLI](./cli.md#init) for every flag.\n\n---\n\n## Fastest path - a downloadable, ready-to-run example\n\nRather have a working project on your machine than type code into a blank\nfile? One command scaffolds a complete SvelteKit app with everything already\nwired up:\n\n```bash\nnpm create @svgrid/studio@latest my-app\n```\n\nYou'll be asked to pick a **theme** - one of `@svgrid/grid`'s 19 built-in\npresets (shadcn, Tailwind, Material, Excel, Fluent, and more) - and whether to\nstart in **light or dark** mode. Scripting this instead? Both are flags:\n\n```bash\nnpm create @svgrid/studio@latest my-app -- --theme material --dark\n```\n\nThen:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nOpen `http://localhost:5173`. You get:\n\n- A **nav shell** and a home page (`src/routes/+layout.svelte`).\n- Two linked entities - **Customers** and **Orders** - each a full grid +\n modal create/edit/delete screen. Orders has a searchable lookup back to\n Customers, so you can see how relations work.\n- **Seeded in-memory data** - nothing to install or configure, no database.\n- The **theme and mode you picked**, applied to the whole app - not just the grid.\n\nIt is a real project, not a read-only demo - edit it, add fields, connect a\ndatabase, deploy it. A few places to start:\n\n| Want to... | Edit |\n| --- | --- |\n| Add or change a field | `src/lib/schemas.ts` - the grid and the form update together |\n| See how a screen is built | `src/lib/EntityScreen.svelte` - the reusable grid + modal CRUD screen every route uses |\n| Connect a real database | `src/lib/data.ts` - swap `createInMemoryDataSource` for `createSqlDataSource` / `createSupabaseDataSource` (see [Databases](./databases.md)) |\n| Add another screen from a live table | `npx @svgrid/studio add invoices --db postgres --url \"$DATABASE_URL\"` |\n\nThe template's own `README.md` covers the same ground once you're in the\nproject. Prefer to see each piece built up by hand instead, or add a screen to\nan *existing* app rather than a fresh one? Continue below.\n\n---\n\n## Three ways to build - pick yours\n\n![Build a data app in four no-code steps: open the designer, start from a sample or your database, arrange it visually, then generate the app.](/docs-media/studio-nocode-steps.svg)\n\nThis is the visual designer - a screens list on the left, your data previewed\nlive in the middle, and simple property panels on the right. You point, click, and\npress **Generate**:\n\n![The visual app designer with a customer grid previewed live and a properties panel for the screen and its fields.](/docs-media/studio-app-designer.png)\n\n- **Visual designer (no code).** You never write code - you point, click, and\n preview, then press one button to generate the finished app. Try it\n immediately, no install, at **[svgrid.com/studio](https://svgrid.com/studio)** -\n or run it locally with **[Launch the designer](./launch.md)**, which\n auto-saves to disk and generates straight into a folder. The\n [sample apps](./samples.md) let you open a complete, realistic app in one\n click and point it at your own data.\n- **CLI.** One deterministic command per screen: `npx @svgrid/studio add ...`\n introspects your table or schema and writes the files. No AI involved. This\n page uses the CLI from Step 3 on - continue below.\n- **AI via MCP.** With [`@svgrid/mcp`](./ai-generation.md) configured, ask your\n coding agent to build the screen; it introspects, scaffolds, and\n compile-verifies through the same core the CLI uses.\n\nAll three produce the **same generated code** - pick whichever fits how you\nwork, and switch freely later.\n\n---\n\n## What you need\n\n- **[Node.js](https://nodejs.org) 18 or newer.** Check with `node -v` in a\n terminal. If that errors, install Node first.\n- **A terminal** and a code editor (VS Code is fine).\n- **A SvelteKit app.** Don't have one? Create one in 30 seconds:\n\n ```bash\n npx sv create my-app # pick \"SvelteKit minimal\", TypeScript: yes\n cd my-app\n npm install\n ```\n\nYou do **not** need a database to start - the first screen below runs on\nin-memory data. You can point it at PostgreSQL, Supabase, MySQL, and others\nlater without changing the UI.\n\n---\n\n## Step 1 - Install\n\nInside your app folder:\n\n```bash\nnpm i @svgrid/grid @svgrid/enterprise\n```\n\n- `@svgrid/grid` is the grid itself.\n- `@svgrid/enterprise` adds Studio: the schema, the edit form, and the data-source\n helpers. It comes with a 30-day evaluation period; loaded without a key\n it displays a watermark and a console message about the Enterprise\n package, and everything still runs. See [licensing](../licensing.md).\n\n---\n\n## Step 2 - Describe your data once\n\nThe generator needs one description of your table. If you have a live database\nit can introspect it directly (Step 3 shows that variant). Here we stay\ndatabase-free: describe the table in a small Drizzle schema file, which the\ngenerator **reads as text** - it never connects to anything.\n\n```bash\nnpm i -D drizzle-orm\n```\n\n(`drizzle-orm` is only there so the schema file type-checks; nothing runs\nagainst a database. It is also the natural next step when you do add one.)\n\nCreate `src/lib/db/schema.ts`:\n\n```ts\nimport { pgTable, text, integer, boolean } from 'drizzle-orm/pg-core'\n\nexport const customers = pgTable('customers', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n email: text('email').notNull(),\n tier: text('tier').notNull().default('free'),\n mrr: integer('mrr'),\n active: boolean('active'),\n})\n```\n\nA Prisma `schema.prisma` works the same way. Prefer to write Studio's own\nmodel - the `EntitySchema` - by hand instead? That is the\n[appendix](#appendix-wire-it-by-hand-no-generator) at the bottom of this page.\n\n---\n\n## Step 3 - Generate the screen\n\nOne command:\n\n```bash\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\n```\n\nHave a live database instead? Same command, different source - no schema file\nneeded:\n\n```bash\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\n```\n\nEither way it writes **three files**, and the screen is done. A quick tour of\nwhat you now own:\n\n**1. `src/lib/customers.schema.ts` - the model.** The generator turned your\ntable into an `EntitySchema` - the single object that drives the grid columns,\nthe form fields, and validation:\n\n```ts\nexport type CustomersRow = {\n id: string\n name: string\n email: string\n tier: string\n mrr: number | null\n active: boolean | null\n}\n\nexport const customersSchema: EntitySchema<CustomersRow> = {\n name: 'customers',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true },\n { field: 'email', type: 'text', required: true },\n // ...\n ],\n}\n```\n\nEvery field option (labels, enum choices, min/max, regex, custom validators) is\nexplained in [The EntitySchema](./schema.md).\n\n**2. `src/routes/api/customers/+server.ts` - the API route.** A\n`ServerDataSource` (read + create + update + delete) exposed over one SvelteKit\nendpoint. With `--from` it starts in-memory so it runs immediately; with `--db`\nit is already wired to your database:\n\n```ts\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\n\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\nSwapping in a real database later means replacing that one `source` line - the\npage never changes. See [Databases](./databases.md).\n\n**3. `src/routes/customers/+page.svelte` - the screen.** The grid with\nserver-side sort, filter, global search, a native pagination footer,\nmulti-select delete with optimistic updates, and a modal create / edit form -\nall reading through the API route:\n\n```ts\nconst source = createKitDataSource<CustomersRow>({ endpoint: '/api/customers' })\nconst columns = schemaToColumns(customersSchema)\nconst controller = createServerDataSource<CustomersRow>(source, {\n pageSize: 25, optimistic: true,\n getRowId: (r) => String(r.id),\n onChange: (s) => (state = s),\n})\n```\n\nEach file wraps its generated body in `// svgrid:managed:start` /\n`// svgrid:managed:end` markers. Everything you write **outside** the markers\nis yours; re-running `add` only rewrites what is inside. That is what makes\nStep 5 safe.\n\n> One thing the generated screen inherits from your page: the **font**. A bare\n> `npx sv create` app sets no CSS at all, so add a\n> `body { font-family: system-ui, sans-serif }` rule (or a\n> [`--sg-font`](./theming.md) token) once, or the page renders in the\n> browser's default serif. Borders, backgrounds, and hover states the grid\n> themes itself.\n\n---\n\n## Step 4 - Run it\n\n```bash\nnpm run dev\n```\n\nOpen the URL it prints (usually `http://localhost:5173`) and go to\n**`/customers`**. With `--from` the grid starts empty (in-memory source, no\nseed) - click **New** and add two or three customers, then try the screen:\n\n- Click a **column header** to sort.\n- Type in the **filter row** under a header to filter (it stays focused as you type).\n- Click a **row** to edit. The form validates as you type - clear a required\n field and watch it complain.\n- Select rows with the checkboxes and **Delete** them - the grid updates\n instantly and rolls back if the server says no.\n- Page through with the **native pager** at the bottom.\n\n![A CRUD screen generated by SvGrid Studio: sortable, filterable grid with a native pager, over a live data source.](/docs-media/studio-crud.png)\n\n---\n\n## Step 5 - Change something\n\nBecause the schema drives everything, changes are one edit. Add a column to\n`src/lib/db/schema.ts`:\n\n```ts\ncountry: text('country'),\n```\n\nThen re-run the exact same command:\n\n```bash\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\n```\n\nThe managed regions are regenerated: the grid gets a **Country** column and the\nedit form gets a **Country** input. Anything you wrote outside the\n`svgrid:managed` markers - extra buttons, styles, handlers - is untouched.\nThat round trip (change schema, re-run, keep your code) is the everyday\nworkflow; [Code generation](./code-generation.md) explains the rules.\n\nPrefer not to re-run the generator? Editing the generated\n`customers.schema.ts` directly works too - grid and form update together from\nthe one schema object.\n\n---\n\n## Where to go next\n\nYou have the whole shape now. The usual next steps:\n\n- **Understand the model** - [Concepts](./concepts.md) walks the pipeline\n (schema, screens, data source, codegen) once and defines every Studio term.\n- **Connect a real database** - re-run `add` with `--db` and a connection\n string, or swap the one `source` line in the API route; the page does not\n change. For Supabase, follow the one-page\n **[Supabase CRUD grid tutorial](./supabase-grid.md)**; for SQL, see\n [Databases](./databases.md).\n- **Design visually** - `npx @svgrid/studio designer` opens the full app builder:\n compose screens across entities, bind data, and click *Generate app*. See the\n [Visual app designer](./app-designer.md). (To embed a single-entity schema editor\n in your own app, see the [Schema designer](./designer.md).)\n- **Build a full app** - the [Build a CRM tutorial](./tutorial-crm.md) wires up\n companies, contacts, and deals with relations and master-detail.\n\n---\n\n## Appendix: wire it by hand (no generator)\n\nEverything the generator wrote in Step 3 can be built up by hand - useful when\nyou want to see exactly how the pieces fit, or to embed a Studio screen in an\nunusual spot. Two files replace the three generated ones (no API route: here\nthe data source lives in the page itself).\n\nFirst, the `EntitySchema` - Studio's own model, the object the generator\nderived from your Drizzle file. Create `src/lib/customers.ts`:\n\n```ts\nimport type { EntitySchema } from '@svgrid/enterprise'\n\nexport type Customer = {\n id: string\n name: string\n email: string\n tier: 'free' | 'pro' | 'enterprise'\n mrr: number\n active: boolean\n}\n\nexport const customersSchema: EntitySchema<Customer> = {\n name: 'customers',\n label: 'Customer',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true, minLength: 2 },\n { field: 'email', type: 'text', label: 'Email', required: true, format: 'email' },\n { field: 'tier', type: 'enum', options: [\n { value: 'free', label: 'Free' },\n { value: 'pro', label: 'Pro' },\n { value: 'enterprise', label: 'Enterprise' },\n ] },\n { field: 'mrr', type: 'number', label: 'MRR ($)', min: 0 },\n { field: 'active', type: 'boolean' },\n ],\n}\n```\n\nThen the page. `createInMemoryDataSource` provides the `ServerDataSource`\ncontract over a plain array, `createServerDataSource` runs sort / filter /\npage / CRUD against it, and the grid + edit panel render it. Create\n`src/routes/customers/+page.svelte`:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'\n import { SvGridEditPanel, createInMemoryDataSource, schemaToColumns } from '@svgrid/enterprise'\n import { customersSchema, type Customer } from '$lib/customers'\n\n const seed: Customer[] = [\n { id: 'c1', name: 'Ada Lovelace', email: 'ada@analytic.io', tier: 'enterprise', mrr: 1200, active: true },\n { id: 'c2', name: 'Alan Turing', email: 'alan@bletchley.uk', tier: 'pro', mrr: 240, active: true },\n { id: 'c3', name: 'Grace Hopper', email: 'grace@navy.mil', tier: 'enterprise', mrr: 980, active: true },\n ]\n\n const columns = schemaToColumns(customersSchema)\n const source = createInMemoryDataSource(seed, customersSchema)\n\n let view = $state<ServerState<Customer>>({\n rows: [], total: 0, loading: false, saving: false, error: null,\n pageIndex: 0, pageSize: 10, pageCount: 1, sortModel: [], filterModel: {},\n })\n let editing = $state<Customer | null | undefined>(undefined)\n let genId = 4\n\n const controller = createServerDataSource(source, {\n pageSize: 10, optimistic: true, getRowId: (r) => r.id,\n onChange: (s) => (view = s),\n })\n controller.refresh()\n\n async function save({ mode, id, values }) {\n if (mode === 'create') { await controller.createRow({ id: `c${genId++}`, ...values }); controller.setPage(view.pageCount - 1) }\n else if (id) { await controller.updateRow(id, values) }\n editing = undefined\n }\n</script>\n\n<style>\n :global(body) {\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n }\n</style>\n\n<button onclick={() => (editing = null)}>+ New customer</button>\n\n<SvGrid\n data={view.rows} {columns} loading={view.loading}\n fitColumns enableRowSummaries={false}\n sortable externalSort onSortingChange={(s) => controller.setSort(s)}\n filterable filterMode=\"row\" externalFilter\n onFiltersChange={(f) => controller.setFilter({\n global: f.global || undefined,\n columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])),\n })}\n onRowClick={(e) => (editing = e.row)}\n showPagination externalPagination\n rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\n onPaginationChange={({ pageIndex, pageSize }) => pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex)}\n/>\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={customersSchema} row={editing} presentation=\"modal\"\n onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}\n```\n\nThe `<style>` block is just a plain font reset - a fresh `npx sv create` app ships no CSS\nat all, so without it the page falls back to the browser's default serif font. `<SvGrid>`\nand `<SvGridEditPanel>` already theme their own borders, backgrounds, and hover states out\nof the box (via [`--sg-*` tokens](../../help/tokens.md) with built-in fallbacks) - font is\nthe one thing they intentionally inherit from the page rather than force, so it fits\nwhatever type your app already uses. If your app already sets a body font (or a\n[`--sg-font`](./theming.md) token), skip this block.\n\n---\n\n<!-- tutorial:install-studio -->\n<figure class=\"docs-tutorial\" id=\"tutorial-install-studio\" data-docs-tutorial=\"install-studio\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-studio.mp4\" poster=\"/tutorials/install-studio.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"SvGrid Studio: database to CRUD app, 72 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-studio.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/install-studio.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>SvGrid Studio: database to CRUD app</strong> (72 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>SvGrid Studio turns tables into a SvelteKit app. From the command line, one create command scaffolds a runnable app with sample tables.</p>\n<p>Point init at a real database and it reads the tables, then writes a list, an edit form and a record page for each one.</p>\n<p>The same generator runs in the browser. The designer opens with a wizard: start from sample data, your own database, or blank.</p>\n<p>Pick a dataset, choose which pages each table gets, and open the app.</p>\n<p>Two tables became six screens: an overview with a KPI and a chart, a list, a manage page and a detail page per table, all on the canvas to rearrange.</p>\n<p>Preview runs the app as a user sees it: the grid, the new record button, the navigation.</p>\n<p>Generate app writes the SvelteKit project: twenty-seven files, typed schemas, routes and a CI workflow. Download the zip or open it in StackBlitz.</p>\n</details>\n</figure>\n<!-- /tutorial:install-studio -->\n\n## See also\n\n- [SvGrid Studio overview](../studio.md)\n- [Concepts](./concepts.md) - the mental model + glossary\n- [Data binding](./data-binding.md) - the `ServerDataSource` contract in depth\n- [Edit forms & validation](./edit-forms.md)\n- [Troubleshooting & FAQ](./troubleshooting.md)\n"
3726
+ "markdown": "# Getting started\n\nThis is the gentlest path into SvGrid Studio. By the end you will have a real,\nworking **Customers** screen - a grid you can sort, filter, and page, with a\ncreate / edit form and delete - running on your machine. No prior experience\nwith SvGrid is assumed.\n\nIf you would rather just click around first, open a live demo - no install\nneeded:\n\n- **[Studio live SQL](https://svgrid.com/demos/193-studio-live-sql/)** - the whole stack in the browser, backed by real Postgres via PGlite\n- **[Supabase](https://svgrid.com/demos/194-studio-supabase/)** - connect your own hosted Postgres\n- **[The designer itself](https://svgrid.com/studio)** - open a sample app and edit it, nothing to install\n\n**Choose your tutorial.** This page needs no database and teaches the whole\nshape; the one-page tutorials build the same screen against a real backend.\nThey all end in the same place, so pick by what you have:\n\n| You have | Follow | What it adds |\n| --- | --- | --- |\n| Nothing yet | this page | the full path: install, schema, generate, run, change |\n| A Postgres connection string | [Postgres CRUD grid](./postgres-grid.md) | a server route querying your database |\n| A Supabase project | [Supabase CRUD grid](./supabase-grid.md) | browser client, keys, Row-Level Security |\n| An HTTP / JSON API | [REST CRUD grid](./rest-grid.md) | the REST adapter, no server route at all |\n| An afternoon | [Build a CRM](./tutorial-crm.md) | multi-entity: relations, master-detail, a real DB |\n\n---\n\n<!-- tutorial:install-studio -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-install-studio\" data-docs-tutorial=\"install-studio\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-studio.mp4\" poster=\"/tutorials/install-studio.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"SvGrid Studio: database to CRUD app, 69 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-studio.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/install-studio.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>SvGrid Studio: database to CRUD app</strong> (69 s, with narration).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>SvGrid Studio turns tables into a SvelteKit app. From the command line, one create command scaffolds a runnable app with sample tables.</p>\n<p>Point init at a real database and it reads the tables, then writes a list, an edit form and a record page for each one.</p>\n<p>The same generator runs in the browser. The designer opens with a wizard: start from sample data, your own database, or blank.</p>\n<p>Pick a dataset, choose which pages each table gets, and open the app.</p>\n<p>Two tables became six screens: an overview with a KPI and a chart, a list, a manage page and a detail page per table, all on the canvas to rearrange.</p>\n<p>Preview runs the app as a user sees it: the grid, the new record button, the navigation.</p>\n<p>Generate app writes the SvelteKit project: twenty-seven files, typed schemas, routes and a CI workflow. Download the zip or open it in StackBlitz.</p>\n</details>\n</figure>\n<!-- /tutorial:install-studio -->\n\n## Guided path - answer a few questions, get the app\n\nIf you already know where your data lives, let Studio ask:\n\n```bash\nnpx @svgrid/studio init\n```\n\nIt asks four things - where the data comes from (sample data, your database,\nin-browser Postgres, or a REST API), which tables you want, which pages each\ntable gets, and what it should look like - then writes a runnable SvelteKit app\nwith a list, an edit form and a record page per table, plus an overview\ndashboard.\n\nPointing it at a real database is one line, and Studio installs the driver for\nyou:\n\n```bash\nnpx @svgrid/studio init --db postgres --url $DATABASE_URL --out my-app\n```\n\nOn Supabase there is no driver to install at all - it is read over the project's\nREST API:\n\n```bash\nnpx @svgrid/studio init --supabase-url https://xxxx.supabase.co --supabase-key $SUPABASE_ANON_KEY\n```\n\nPrefer clicking? The visual designer has the same wizard behind its **New app**\nbutton - or open [svgrid.com/studio/new](https://svgrid.com/studio/new) to start\none in the browser. Both paths run the same generator, so they produce the same\napp. See [The Studio CLI](./cli.md#init) for every flag.\n\n---\n\n## Fastest path - a downloadable, ready-to-run example\n\nRather have a working project on your machine than type code into a blank\nfile? One command scaffolds a complete SvelteKit app with everything already\nwired up:\n\n```bash\nnpm create @svgrid/studio@latest my-app\n```\n\nYou'll be asked to pick a **theme** - one of `@svgrid/grid`'s 19 built-in\npresets (shadcn, Tailwind, Material, Excel, Fluent, and more) - and whether to\nstart in **light or dark** mode. Scripting this instead? Both are flags:\n\n```bash\nnpm create @svgrid/studio@latest my-app -- --theme material --dark\n```\n\nThen:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\nOpen `http://localhost:5173`. You get:\n\n- A **nav shell** and a home page (`src/routes/+layout.svelte`).\n- Two linked entities - **Customers** and **Orders** - each a full grid +\n modal create/edit/delete screen. Orders has a searchable lookup back to\n Customers, so you can see how relations work.\n- **Seeded in-memory data** - nothing to install or configure, no database.\n- The **theme and mode you picked**, applied to the whole app - not just the grid.\n\nIt is a real project, not a read-only demo - edit it, add fields, connect a\ndatabase, deploy it. A few places to start:\n\n| Want to... | Edit |\n| --- | --- |\n| Add or change a field | `src/lib/schemas.ts` - the grid and the form update together |\n| See how a screen is built | `src/lib/EntityScreen.svelte` - the reusable grid + modal CRUD screen every route uses |\n| Connect a real database | `src/lib/data.ts` - swap `createInMemoryDataSource` for `createSqlDataSource` / `createSupabaseDataSource` (see [Databases](./databases.md)) |\n| Add another screen from a live table | `npx @svgrid/studio add invoices --db postgres --url \"$DATABASE_URL\"` |\n\nThe template's own `README.md` covers the same ground once you're in the\nproject. Prefer to see each piece built up by hand instead, or add a screen to\nan *existing* app rather than a fresh one? Continue below.\n\n---\n\n## Three ways to build - pick yours\n\n![Build a data app in four no-code steps: open the designer, start from a sample or your database, arrange it visually, then generate the app.](/docs-media/studio-nocode-steps.svg)\n\nThis is the visual designer - a screens list on the left, your data previewed\nlive in the middle, and simple property panels on the right. You point, click, and\npress **Generate**:\n\n![The visual app designer with a customer grid previewed live and a properties panel for the screen and its fields.](/docs-media/studio-app-designer.png)\n\n- **Visual designer (no code).** You never write code - you point, click, and\n preview, then press one button to generate the finished app. Try it\n immediately, no install, at **[svgrid.com/studio](https://svgrid.com/studio)** -\n or run it locally with **[Launch the designer](./launch.md)**, which\n auto-saves to disk and generates straight into a folder. The\n [sample apps](./samples.md) let you open a complete, realistic app in one\n click and point it at your own data.\n- **CLI.** One deterministic command per screen: `npx @svgrid/studio add ...`\n introspects your table or schema and writes the files. No AI involved. This\n page uses the CLI from Step 3 on - continue below.\n- **AI via MCP.** With [`@svgrid/mcp`](./ai-generation.md) configured, ask your\n coding agent to build the screen; it introspects, scaffolds, and\n compile-verifies through the same core the CLI uses.\n\nAll three produce the **same generated code** - pick whichever fits how you\nwork, and switch freely later.\n\n---\n\n## What you need\n\n- **[Node.js](https://nodejs.org) 18 or newer.** Check with `node -v` in a\n terminal. If that errors, install Node first.\n- **A terminal** and a code editor (VS Code is fine).\n- **A SvelteKit app.** Don't have one? Create one in 30 seconds:\n\n ```bash\n npx sv create my-app # pick \"SvelteKit minimal\", TypeScript: yes\n cd my-app\n npm install\n ```\n\nYou do **not** need a database to start - the first screen below runs on\nin-memory data. You can point it at PostgreSQL, Supabase, MySQL, and others\nlater without changing the UI.\n\n---\n\n## Step 1 - Install\n\nInside your app folder:\n\n```bash\nnpm i @svgrid/grid @svgrid/enterprise\n```\n\n- `@svgrid/grid` is the grid itself.\n- `@svgrid/enterprise` adds Studio: the schema, the edit form, and the data-source\n helpers. It comes with a 30-day evaluation period; loaded without a key\n it displays a watermark and a console message about the Enterprise\n package, and everything still runs. See [licensing](../licensing.md).\n\n---\n\n## Step 2 - Describe your data once\n\nThe generator needs one description of your table. If you have a live database\nit can introspect it directly (Step 3 shows that variant). Here we stay\ndatabase-free: describe the table in a small Drizzle schema file, which the\ngenerator **reads as text** - it never connects to anything.\n\n```bash\nnpm i -D drizzle-orm\n```\n\n(`drizzle-orm` is only there so the schema file type-checks; nothing runs\nagainst a database. It is also the natural next step when you do add one.)\n\nCreate `src/lib/db/schema.ts`:\n\n```ts\nimport { pgTable, text, integer, boolean } from 'drizzle-orm/pg-core'\n\nexport const customers = pgTable('customers', {\n id: text('id').primaryKey(),\n name: text('name').notNull(),\n email: text('email').notNull(),\n tier: text('tier').notNull().default('free'),\n mrr: integer('mrr'),\n active: boolean('active'),\n})\n```\n\nA Prisma `schema.prisma` works the same way. Prefer to write Studio's own\nmodel - the `EntitySchema` - by hand instead? That is the\n[appendix](#appendix-wire-it-by-hand-no-generator) at the bottom of this page.\n\n---\n\n## Step 3 - Generate the screen\n\nOne command:\n\n```bash\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\n```\n\nHave a live database instead? Same command, different source - no schema file\nneeded:\n\n```bash\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\n```\n\nEither way it writes **three files**, and the screen is done. A quick tour of\nwhat you now own:\n\n**1. `src/lib/customers.schema.ts` - the model.** The generator turned your\ntable into an `EntitySchema` - the single object that drives the grid columns,\nthe form fields, and validation:\n\n```ts\nexport type CustomersRow = {\n id: string\n name: string\n email: string\n tier: string\n mrr: number | null\n active: boolean | null\n}\n\nexport const customersSchema: EntitySchema<CustomersRow> = {\n name: 'customers',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true },\n { field: 'email', type: 'text', required: true },\n // ...\n ],\n}\n```\n\nEvery field option (labels, enum choices, min/max, regex, custom validators) is\nexplained in [The EntitySchema](./schema.md).\n\n**2. `src/routes/api/customers/+server.ts` - the API route.** A\n`ServerDataSource` (read + create + update + delete) exposed over one SvelteKit\nendpoint. With `--from` it starts in-memory so it runs immediately; with `--db`\nit is already wired to your database:\n\n```ts\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\n\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\nSwapping in a real database later means replacing that one `source` line - the\npage never changes. See [Databases](./databases.md).\n\n**3. `src/routes/customers/+page.svelte` - the screen.** The grid with\nserver-side sort, filter, global search, a native pagination footer,\nmulti-select delete with optimistic updates, and a modal create / edit form -\nall reading through the API route:\n\n```ts\nconst source = createKitDataSource<CustomersRow>({ endpoint: '/api/customers' })\nconst columns = schemaToColumns(customersSchema)\nconst controller = createServerDataSource<CustomersRow>(source, {\n pageSize: 25, optimistic: true,\n getRowId: (r) => String(r.id),\n onChange: (s) => (state = s),\n})\n```\n\nEach file wraps its generated body in `// svgrid:managed:start` /\n`// svgrid:managed:end` markers. Everything you write **outside** the markers\nis yours; re-running `add` only rewrites what is inside. That is what makes\nStep 5 safe.\n\n> One thing the generated screen inherits from your page: the **font**. A bare\n> `npx sv create` app sets no CSS at all, so add a\n> `body { font-family: system-ui, sans-serif }` rule (or a\n> [`--sg-font`](./theming.md) token) once, or the page renders in the\n> browser's default serif. Borders, backgrounds, and hover states the grid\n> themes itself.\n\n---\n\n## Step 4 - Run it\n\n```bash\nnpm run dev\n```\n\nOpen the URL it prints (usually `http://localhost:5173`) and go to\n**`/customers`**. With `--from` the grid starts empty (in-memory source, no\nseed) - click **New** and add two or three customers, then try the screen:\n\n- Click a **column header** to sort.\n- Type in the **filter row** under a header to filter (it stays focused as you type).\n- Click a **row** to edit. The form validates as you type - clear a required\n field and watch it complain.\n- Select rows with the checkboxes and **Delete** them - the grid updates\n instantly and rolls back if the server says no.\n- Page through with the **native pager** at the bottom.\n\n![A CRUD screen generated by SvGrid Studio: sortable, filterable grid with a native pager, over a live data source.](/docs-media/studio-crud.png)\n\n---\n\n## Step 5 - Change something\n\nBecause the schema drives everything, changes are one edit. Add a column to\n`src/lib/db/schema.ts`:\n\n```ts\ncountry: text('country'),\n```\n\nThen re-run the exact same command:\n\n```bash\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\n```\n\nThe managed regions are regenerated: the grid gets a **Country** column and the\nedit form gets a **Country** input. Anything you wrote outside the\n`svgrid:managed` markers - extra buttons, styles, handlers - is untouched.\nThat round trip (change schema, re-run, keep your code) is the everyday\nworkflow; [Code generation](./code-generation.md) explains the rules.\n\nPrefer not to re-run the generator? Editing the generated\n`customers.schema.ts` directly works too - grid and form update together from\nthe one schema object.\n\n---\n\n## Where to go next\n\nYou have the whole shape now. The usual next steps:\n\n- **Understand the model** - [Concepts](./concepts.md) walks the pipeline\n (schema, screens, data source, codegen) once and defines every Studio term.\n- **Connect a real database** - re-run `add` with `--db` and a connection\n string, or swap the one `source` line in the API route; the page does not\n change. For Supabase, follow the one-page\n **[Supabase CRUD grid tutorial](./supabase-grid.md)**; for SQL, see\n [Databases](./databases.md).\n- **Design visually** - `npx @svgrid/studio designer` opens the full app builder:\n compose screens across entities, bind data, and click *Generate app*. See the\n [Visual app designer](./app-designer.md). (To embed a single-entity schema editor\n in your own app, see the [Schema designer](./designer.md).)\n- **Build a full app** - the [Build a CRM tutorial](./tutorial-crm.md) wires up\n companies, contacts, and deals with relations and master-detail.\n\n---\n\n## Appendix: wire it by hand (no generator)\n\nEverything the generator wrote in Step 3 can be built up by hand - useful when\nyou want to see exactly how the pieces fit, or to embed a Studio screen in an\nunusual spot. Two files replace the three generated ones (no API route: here\nthe data source lives in the page itself).\n\nFirst, the `EntitySchema` - Studio's own model, the object the generator\nderived from your Drizzle file. Create `src/lib/customers.ts`:\n\n```ts\nimport type { EntitySchema } from '@svgrid/enterprise'\n\nexport type Customer = {\n id: string\n name: string\n email: string\n tier: 'free' | 'pro' | 'enterprise'\n mrr: number\n active: boolean\n}\n\nexport const customersSchema: EntitySchema<Customer> = {\n name: 'customers',\n label: 'Customer',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true, minLength: 2 },\n { field: 'email', type: 'text', label: 'Email', required: true, format: 'email' },\n { field: 'tier', type: 'enum', options: [\n { value: 'free', label: 'Free' },\n { value: 'pro', label: 'Pro' },\n { value: 'enterprise', label: 'Enterprise' },\n ] },\n { field: 'mrr', type: 'number', label: 'MRR ($)', min: 0 },\n { field: 'active', type: 'boolean' },\n ],\n}\n```\n\nThen the page. `createInMemoryDataSource` provides the `ServerDataSource`\ncontract over a plain array, `createServerDataSource` runs sort / filter /\npage / CRUD against it, and the grid + edit panel render it. Create\n`src/routes/customers/+page.svelte`:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'\n import { SvGridEditPanel, createInMemoryDataSource, schemaToColumns } from '@svgrid/enterprise'\n import { customersSchema, type Customer } from '$lib/customers'\n\n const seed: Customer[] = [\n { id: 'c1', name: 'Ada Lovelace', email: 'ada@analytic.io', tier: 'enterprise', mrr: 1200, active: true },\n { id: 'c2', name: 'Alan Turing', email: 'alan@bletchley.uk', tier: 'pro', mrr: 240, active: true },\n { id: 'c3', name: 'Grace Hopper', email: 'grace@navy.mil', tier: 'enterprise', mrr: 980, active: true },\n ]\n\n const columns = schemaToColumns(customersSchema)\n const source = createInMemoryDataSource(seed, customersSchema)\n\n let view = $state<ServerState<Customer>>({\n rows: [], total: 0, loading: false, saving: false, error: null,\n pageIndex: 0, pageSize: 10, pageCount: 1, sortModel: [], filterModel: {},\n })\n let editing = $state<Customer | null | undefined>(undefined)\n let genId = 4\n\n const controller = createServerDataSource(source, {\n pageSize: 10, optimistic: true, getRowId: (r) => r.id,\n onChange: (s) => (view = s),\n })\n controller.refresh()\n\n async function save({ mode, id, values }) {\n if (mode === 'create') { await controller.createRow({ id: `c${genId++}`, ...values }); controller.setPage(view.pageCount - 1) }\n else if (id) { await controller.updateRow(id, values) }\n editing = undefined\n }\n</script>\n\n<style>\n :global(body) {\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n }\n</style>\n\n<button onclick={() => (editing = null)}>+ New customer</button>\n\n<SvGrid\n data={view.rows} {columns} loading={view.loading}\n fitColumns enableRowSummaries={false}\n sortable externalSort onSortingChange={(s) => controller.setSort(s)}\n filterable filterMode=\"row\" externalFilter\n onFiltersChange={(f) => controller.setFilter({\n global: f.global || undefined,\n columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])),\n })}\n onRowClick={(e) => (editing = e.row)}\n showPagination externalPagination\n rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\n onPaginationChange={({ pageIndex, pageSize }) => pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex)}\n/>\n\n{#if editing !== undefined}\n <SvGridEditPanel schema={customersSchema} row={editing} presentation=\"modal\"\n onSubmit={save} onCancel={() => (editing = undefined)} />\n{/if}\n```\n\nThe `<style>` block is just a plain font reset - a fresh `npx sv create` app ships no CSS\nat all, so without it the page falls back to the browser's default serif font. `<SvGrid>`\nand `<SvGridEditPanel>` already theme their own borders, backgrounds, and hover states out\nof the box (via [`--sg-*` tokens](../../help/tokens.md) with built-in fallbacks) - font is\nthe one thing they intentionally inherit from the page rather than force, so it fits\nwhatever type your app already uses. If your app already sets a body font (or a\n[`--sg-font`](./theming.md) token), skip this block.\n\n---\n\n## See also\n\n- [SvGrid Studio overview](../studio.md)\n- [Concepts](./concepts.md) - the mental model + glossary\n- [Data binding](./data-binding.md) - the `ServerDataSource` contract in depth\n- [Edit forms & validation](./edit-forms.md)\n- [Troubleshooting & FAQ](./troubleshooting.md)\n"
3727
3727
  },
3728
3728
  {
3729
3729
  "slug": "enterprise/studio/i18n",
@@ -3919,42 +3919,49 @@ export const docs = [
3919
3919
  "path": "docs/getting-started/1-install.md",
3920
3920
  "title": "1. Install",
3921
3921
  "section": "Getting started",
3922
- "markdown": "# 1. Install\n\n> Step 1 of 6 · [Next: First grid →](./2-first-grid.md)\n\nSvGrid is a single npm package. There is no peer dependency on a CSS\nframework - bring your own, or import one of the 20 themes that ship\nwith it. Each theme carries a light and a dark palette, so dark mode is\nan attribute, not a second stylesheet.\n\n![Two install paths side by side: scaffold a ready-wired project with npm create @svgrid@latest, or add the @svgrid/grid package manually and import SvGrid into your markup.](/docs-media/gs-install.svg)\n\n## Fastest start: scaffold a project\n\nStarting fresh? Skip the manual wiring and scaffold a project with the\ngrid already set up:\n\n```bash\nnpm create @svgrid@latest # interactive\nnpm create @svgrid@latest my-admin -- --template admin-dashboard\n```\n\nSee [Starters & scaffolding](./starters.md) for the templates (minimal\nVite app or a full SvelteKit admin dashboard) and the Deploy-to-Vercel\nflow. Building with **SvelteKit**? [SvGrid with SvelteKit](./sveltekit.md) is the\nend-to-end path: `sv add`, server loads, URL-driven sort, form actions and SSR.\n\nTo add SvGrid to an **existing** app, install it directly:\n\n```bash\n# pnpm (recommended)\npnpm add @svgrid/grid\n\n# npm\nnpm install @svgrid/grid\n\n# yarn\nyarn add @svgrid/grid\n```\n\n<!-- tutorial:install-svgrid -->\n<figure class=\"docs-tutorial\" id=\"tutorial-install-svgrid\" data-docs-tutorial=\"install-svgrid\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-svgrid.mp4\" poster=\"/tutorials/install-svgrid.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Install SvGrid, 51 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-svgrid.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/install-svgrid.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Install SvGrid</strong> (51 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>SvGrid is one npm package. In an existing Svelte 5 project, install it and you are done with setup.</p>\n<p>Starting from scratch? The create command scaffolds a working project with the grid already wired.</p>\n<p>Either way, a grid is a component, a data array and a columns array. Import SvGrid and describe the rows.</p>\n<p>Columns map a field to a header. Then render the component with both.</p>\n<p>Save, and the dev server shows a styled table with keyboard navigation, selection and a focus ring, all out of the box.</p>\n<p>Sorting, filtering, editing and the rest are features you switch on next. The docs at svgrid.com take it from here.</p>\n</details>\n</figure>\n<!-- /tutorial:install-svgrid -->\n\n## Requirements\n\n| Tool | Version | Why |\n| ----------- | ----------------- | ------------------------------------- |\n| Svelte | **5.x** | Uses runes: `$state`, `$derived`, `$effect`. |\n| TypeScript | **5.4+** | Optional but strongly recommended. The column-def types pay for themselves. |\n| Node | **18+** | For tooling (`vite`, `svelte-check`, the example gallery). |\n\nThe bundle is tree-shakeable: features you don't import don't ship.\nThere's no monolithic entry that pulls everything.\n\n## Verify the install\n\nA 5-line smoke test:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { name: string }\n\n const rows: Person[] = [{ name: 'Ada' }, { name: 'Linus' }]\n\n // Annotate the array. Without `GridColumns<Person>` TypeScript widens `field`\n // to `string`, and the grid - which types `field` as a key of your row - then\n // rejects it. This is the one type annotation worth writing every time.\n const columns: GridColumns<Person> = [{ field: 'name', header: 'Name' }]\n</script>\n\n<SvGrid data={rows} {columns} />\n```\n\nIf you see a styled `<table>` with two rows, you're done.\n\n## Pick a theme\n\nThe render component ships its own styles, so the grid is readable the\nmoment it mounts. It is deliberately plain, though: no preset is applied\nuntil you ask for one. Import a theme to change that.\n\n```ts\nimport '@svgrid/grid/themes/shadcn.css'\n```\n\nTwenty are available: `ember` (SvGrid's own look), `shadcn`, `tailwind`,\n`material`, `fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`,\n`salesforce`, `sap`, `github`, `linear`, `notion`, `vercel`, `excel`,\n`nord`, `dracula`, `catppuccin`, `ag-alpine`.\n\nEach one declares the whole `--sg-*` token set twice: once on `:root` and\nonce under `:root[data-theme='dark']`. So dark mode is one attribute on\nthe document, and the grid follows:\n\n```ts\ndocument.documentElement.dataset.theme = 'dark'\n```\n\nTwo things worth knowing before you wire this into an existing app:\n\n- **Set `color-scheme` too.** Without it the browser keeps painting native\n scrollbars, form controls and the page canvas light, so a dark grid sits\n in a light frame. `:root { color-scheme: light }` plus\n `:root[data-theme='dark'] { color-scheme: dark }` is the whole fix.\n- **Apply the attribute before the first paint.** Setting it from a\n component means the page renders in the wrong palette for a frame. An\n inline script in `index.html` (or `app.html` in SvelteKit) that reads\n `localStorage` avoids the flash.\n\nThe scaffolded starters do both already. [Step 5](./5-theme-and-density.md)\ncovers overriding individual tokens, per-instance theming and density.\n\n## Enterprise add-on (optional)\n\nIf you need data export (Excel / PDF / CSV), data import, the AI\nassistant, or built-in pivot tables, install the paid Enterprise pack\nalongside the Community package:\n\n```bash\npnpm add @svgrid/enterprise\n```\n\nSee [Enterprise features](../enterprise/README.md) for what ships and how to license.\n\n## Where the rest of this guide goes\n\n1. **Install** ← you're here\n2. [First grid](./2-first-grid.md) - the minimum runnable example explained\n3. [Data and columns](./3-data-and-columns.md) - the two arrays the grid actually reads\n4. [Features](./4-features.md) - opt into sort, filter, pagination, grouping, etc.\n5. [Theme and density](./5-theme-and-density.md) - `--sg-*` tokens, dark mode, row height\n6. [Going to production](./6-going-to-production.md) - server-side data, virtualization, a11y, SSR\n\nThe combined \"everything in one page\" version is at\n[../getting-started-full.md](../getting-started-full.md) - useful for\nprinting or single-tab reading.\n"
3922
+ "markdown": "# 1. Install\n\n> Step 1 of 6 · [Next: First grid →](./2-first-grid.md)\n\nSvGrid is a single npm package. There is no peer dependency on a CSS\nframework - bring your own, or import one of the 20 themes that ship\nwith it. Each theme carries a light and a dark palette, so dark mode is\nan attribute, not a second stylesheet.\n\n![Two install paths side by side: scaffold a ready-wired project with npm create @svgrid@latest, or add the @svgrid/grid package manually and import SvGrid into your markup.](/docs-media/gs-install.svg)\n\n## Fastest start: scaffold a project\n\nStarting fresh? Skip the manual wiring and scaffold a project with the\ngrid already set up:\n\n```bash\nnpm create @svgrid@latest # interactive\nnpm create @svgrid@latest my-admin -- --template admin-dashboard\n```\n\nSee [Starters & scaffolding](./starters.md) for the templates (minimal\nVite app or a full SvelteKit admin dashboard) and the Deploy-to-Vercel\nflow. Building with **SvelteKit**? [SvGrid with SvelteKit](./sveltekit.md) is the\nend-to-end path: `sv add`, server loads, URL-driven sort, form actions and SSR.\n\nTo add SvGrid to an **existing** app, install it directly:\n\n```bash\n# pnpm (recommended)\npnpm add @svgrid/grid\n\n# npm\nnpm install @svgrid/grid\n\n# yarn\nyarn add @svgrid/grid\n```\n\n<!-- tutorial:install-svgrid -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-install-svgrid\" data-docs-tutorial=\"install-svgrid\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-svgrid.mp4\" poster=\"/tutorials/install-svgrid.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"Install SvGrid, 45 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-svgrid.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/install-svgrid.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Install SvGrid</strong> (45 s, with narration).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>SvGrid is one npm package. In an existing Svelte 5 project, install it and you are done with setup.</p>\n<p>Starting from scratch? The create command scaffolds a working project with the grid already wired.</p>\n<p>Either way, a grid is a component, a data array and a columns array. Import SvGrid and describe the rows.</p>\n<p>Columns map a field to a header. Then render the component with both.</p>\n<p>Save, and the dev server shows a styled table with keyboard navigation, selection and a focus ring, all out of the box.</p>\n<p>Sorting, filtering, editing and the rest are features you switch on next. The docs at svgrid.com take it from here.</p>\n</details>\n</figure>\n<!-- /tutorial:install-svgrid -->\n\n## Requirements\n\n| Tool | Version | Why |\n| ----------- | ----------------- | ------------------------------------- |\n| Svelte | **5.x** | Uses runes: `$state`, `$derived`, `$effect`. |\n| TypeScript | **5.4+** | Optional but strongly recommended. The column-def types pay for themselves. |\n| Node | **18+** | For tooling (`vite`, `svelte-check`, the example gallery). |\n\nThe bundle is tree-shakeable: features you don't import don't ship.\nThere's no monolithic entry that pulls everything.\n\n## Verify the install\n\nA 5-line smoke test:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { name: string }\n\n const rows: Person[] = [{ name: 'Ada' }, { name: 'Linus' }]\n\n // Annotate the array. Without `GridColumns<Person>` TypeScript widens `field`\n // to `string`, and the grid - which types `field` as a key of your row - then\n // rejects it. This is the one type annotation worth writing every time.\n const columns: GridColumns<Person> = [{ field: 'name', header: 'Name' }]\n</script>\n\n<SvGrid data={rows} {columns} />\n```\n\nIf you see a styled `<table>` with two rows, you're done.\n\n## Pick a theme\n\nThe render component ships its own styles, so the grid is readable the\nmoment it mounts. It is deliberately plain, though: no preset is applied\nuntil you ask for one. Import a theme to change that.\n\n```ts\nimport '@svgrid/grid/themes/shadcn.css'\n```\n\nTwenty are available: `ember` (SvGrid's own look), `shadcn`, `tailwind`,\n`material`, `fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`,\n`salesforce`, `sap`, `github`, `linear`, `notion`, `vercel`, `excel`,\n`nord`, `dracula`, `catppuccin`, `ag-alpine`.\n\nEach one declares the whole `--sg-*` token set twice: once on `:root` and\nonce under `:root[data-theme='dark']`. So dark mode is one attribute on\nthe document, and the grid follows:\n\n```ts\ndocument.documentElement.dataset.theme = 'dark'\n```\n\nTwo things worth knowing before you wire this into an existing app:\n\n- **Set `color-scheme` too.** Without it the browser keeps painting native\n scrollbars, form controls and the page canvas light, so a dark grid sits\n in a light frame. `:root { color-scheme: light }` plus\n `:root[data-theme='dark'] { color-scheme: dark }` is the whole fix.\n- **Apply the attribute before the first paint.** Setting it from a\n component means the page renders in the wrong palette for a frame. An\n inline script in `index.html` (or `app.html` in SvelteKit) that reads\n `localStorage` avoids the flash.\n\nThe scaffolded starters do both already. [Step 5](./5-theme-and-density.md)\ncovers overriding individual tokens, per-instance theming and density.\n\n## Enterprise add-on (optional)\n\nIf you need data export (Excel / PDF / CSV), data import, the AI\nassistant, or built-in pivot tables, install the paid Enterprise pack\nalongside the Community package:\n\n```bash\npnpm add @svgrid/enterprise\n```\n\nSee [Enterprise features](../enterprise/README.md) for what ships and how to license.\n\n## Where the rest of this guide goes\n\n1. **Install** ← you're here\n2. [First grid](./2-first-grid.md) - the minimum runnable example explained\n3. [Data and columns](./3-data-and-columns.md) - the two arrays the grid actually reads\n4. [Features](./4-features.md) - opt into sort, filter, pagination, grouping, etc.\n5. [Theme and density](./5-theme-and-density.md) - `--sg-*` tokens, dark mode, row height\n6. [Going to production](./6-going-to-production.md) - server-side data, virtualization, a11y, SSR\n\nThe combined \"everything in one page\" version is at\n[../getting-started-full.md](../getting-started-full.md) - useful for\nprinting or single-tab reading.\n"
3923
3923
  },
3924
3924
  {
3925
3925
  "slug": "getting-started/2-first-grid",
3926
3926
  "path": "docs/getting-started/2-first-grid.md",
3927
3927
  "title": "2. First grid in 60 seconds",
3928
3928
  "section": "Getting started",
3929
- "markdown": "# 2. First grid in 60 seconds\n\n> Step 2 of 6 · [← Install](./1-install.md) · [Next: Data and columns →](./3-data-and-columns.md)\n\n![Anatomy of the minimal example: a data rows array and a columns ColumnDef array flow into a SvGrid element that renders a small table with a header row and three body rows.](/docs-media/gs-first-grid.svg)\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { firstName: string; age: number; status: string }\n\n const rows: Person[] = [\n { firstName: 'Ada', age: 36, status: 'active' },\n { firstName: 'Linus', age: 54, status: 'active' },\n { firstName: 'Grace', age: 85, status: 'inactive' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First name' },\n { field: 'age', header: 'Age' },\n { field: 'status', header: 'Status' },\n ]\n</script>\n\n<SvGrid data={rows} columns={columns} />\n```\n\nThat's a complete, working grid.\n\n## What you got out of the box\n\n- A semantic `<table>` with WAI-ARIA `role=\"grid\"` / `role=\"row\"` /\n `role=\"columnheader\"` / `role=\"gridcell\"` on every node.\n- Keyboard navigation: arrow keys move the active cell, Home/End jump\n to row edges, Page Up/Down move by a page, Ctrl+Home / Ctrl+End jump\n to the grid corners.\n- A focus ring on the active cell. Selection on click. F2 / double-click\n to edit (no-op here because no column has `editorType`).\n- Auto-sized columns; explicit `width` on the column def overrides.\n- The default `--sg-*` theme: borders, header background, zebra rows,\n hover, selection - all on tokens you can swap.\n\n## What you didn't get yet\n\nSort, filter, pagination, grouping, expansion, selection are **off**\nuntil you register the matching features. That's the next step.\n\n```svelte\n<!-- after registering rowSortingFeature + columnFilteringFeature -->\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n/>\n```\n\n[Step 3 →](./3-data-and-columns.md) covers how `data` and `columns`\nwork in detail; [step 4 →](./4-features.md) lights up everything else.\n\n## See it run\n\nThe quick-start demo is a slightly fancier version (more columns,\ninline editing, range selection):\n\n<div data-docs-demo=\"01-quick-start\" data-height=\"440\"></div>\n\nSource: [examples/src/demos/01-quick-start.svelte](../../examples/src/demos/01-quick-start.svelte).\n\n## Adding the three common features\n\nEach capability is one boolean. Turn on what the screen needs and nothing\nelse ships - which is why the first grid stays small.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={seed} {columns} sortable filterable pageable pageSize={2} />\n```\n"
3929
+ "markdown": "# 2. First grid in 60 seconds\n\n> Step 2 of 6 · [← Install](./1-install.md) · [Next: Data and columns →](./3-data-and-columns.md)\n\n![Anatomy of the minimal example: a data rows array and a columns ColumnDef array flow into a SvGrid element that renders a small table with a header row and three body rows.](/docs-media/gs-first-grid.svg)\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { firstName: string; age: number; status: string }\n\n const rows: Person[] = [\n { firstName: 'Ada', age: 36, status: 'active' },\n { firstName: 'Linus', age: 54, status: 'active' },\n { firstName: 'Grace', age: 85, status: 'inactive' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First name' },\n { field: 'age', header: 'Age' },\n { field: 'status', header: 'Status' },\n ]\n</script>\n\n<SvGrid data={rows} columns={columns} />\n```\n\nThat's a complete, working grid.\n\n<!-- tutorial:learn-1-first-grid -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-1-first-grid\" data-docs-tutorial=\"learn-1-first-grid\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-1-first-grid.mp4\" poster=\"/tutorials/learn-1-first-grid.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 1: install and your first grid, 121 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-1-first-grid.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-1-first-grid.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 1: install and your first grid</strong> (121 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Welcome to lesson one. By the end of it you will have a working data grid in a Svelte 5 project, and you will understand every line that put it there.</p>\n<p>SvGrid is a single npm package. There is no CLI step and nothing to copy into your project: you install it and import from it.</p>\n<p>It needs Svelte 5, because the grid is built on runes. If you are starting from nothing, the create command scaffolds a project with the grid already wired up.</p>\n<p>Now the component. Import SvGrid, and import the GridColumns type as well. That type is what makes the column list check itself against your rows.</p>\n<p>Your data is a plain array of plain objects. No store, no wrapper, no adapter. If you already have rows in your app, they are already in the right shape.</p>\n<p>Columns are the other half. Each one maps a field on your row to a header the user reads. Because the array is typed as GridColumns of Person, a typo in a field name is a compile error, not a blank column.</p>\n<p>Then render the component with both. That is the whole API for a basic grid: data in, columns in.</p>\n<p>Save, and the dev server shows this. A real table element, with a header row and your three rows in it.</p>\n<p>You did not ask for any of this, but you got it: click a cell and it takes focus, arrow keys move between cells, Home and End jump to the ends of a row. That is the ARIA grid pattern, on by default.</p>\n<p>What you do not have yet is sorting, filtering or paging. Those are switched on deliberately, so a grid you never configured stays small in your bundle. Lesson two puts your own data in, and lesson three turns those features on.</p>\n<p>The code from this lesson is on the page linked below, and it runs there too. See you in lesson two.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-1-first-grid -->\n\n## What you got out of the box\n\n- A semantic `<table>` with WAI-ARIA `role=\"grid\"` / `role=\"row\"` /\n `role=\"columnheader\"` / `role=\"gridcell\"` on every node.\n- Keyboard navigation: arrow keys move the active cell, Home/End jump\n to row edges, Page Up/Down move by a page, Ctrl+Home / Ctrl+End jump\n to the grid corners.\n- A focus ring on the active cell. Selection on click. F2 / double-click\n to edit (no-op here because no column has `editorType`).\n- Auto-sized columns; explicit `width` on the column def overrides.\n- The default `--sg-*` theme: borders, header background, zebra rows,\n hover, selection - all on tokens you can swap.\n\n## What you didn't get yet\n\nSort, filter, pagination, grouping, expansion, selection are **off**\nuntil you register the matching features. That's the next step.\n\n```svelte\n<!-- after registering rowSortingFeature + columnFilteringFeature -->\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n/>\n```\n\n[Step 3 →](./3-data-and-columns.md) covers how `data` and `columns`\nwork in detail; [step 4 →](./4-features.md) lights up everything else.\n\n## See it run\n\nThe quick-start demo is a slightly fancier version (more columns,\ninline editing, range selection):\n\n<div data-docs-demo=\"01-quick-start\" data-height=\"440\"></div>\n\nSource: [examples/src/demos/01-quick-start.svelte](../../examples/src/demos/01-quick-start.svelte).\n\n## Adding the three common features\n\nEach capability is one boolean. Turn on what the screen needs and nothing\nelse ships - which is why the first grid stays small.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={seed} {columns} sortable filterable pageable pageSize={2} />\n```\n"
3930
3930
  },
3931
3931
  {
3932
3932
  "slug": "getting-started/3-data-and-columns",
3933
3933
  "path": "docs/getting-started/3-data-and-columns.md",
3934
3934
  "title": "3. Data and columns",
3935
3935
  "section": "Getting started",
3936
- "markdown": "# 3. Data and columns\n\n> Step 3 of 6 · [← First grid](./2-first-grid.md) · [Next: Features →](./4-features.md)\n\nSvGrid reads two arrays: `data` (your rows) and `columns` (the\ncolumn-definition list). Everything else is opt-in.\n\n![The data array of rows and the columns array of definitions both feed the SvGrid element, with a ColumnDef anatomy callout mapping field to cell value, header to column label, and cell or renderSnippet to a custom render.](/docs-media/gs-data-columns.svg)\n\n## Row data\n\nThe `data` prop is any `ReadonlyArray<TRow>`. A Svelte 5 `$state`\narray, a derived store, an SWR/TanStack-Query cache, a `+page.ts` load\nresult, a plain literal - the grid doesn't care, as long as the array\nreference changes when the rows change.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Staff = { id: string; firstName: string; age: number }\n\n // Reactive: pushing into `rows` updates the grid automatically.\n let rows = $state<Staff[]>([\n { id: '1', firstName: 'Ada', age: 36 },\n { id: '2', firstName: 'Linus', age: 54 },\n ])\n\n const staffColumns: GridColumns<Staff> = [\n { field: 'firstName', header: 'First name', width: 180 },\n { field: 'age', header: 'Age', width: 90 },\n ]\n\n function addRow() {\n rows.push({ id: crypto.randomUUID(), firstName: 'New', age: 0 })\n }\n</script>\n\n<button onclick={addRow}>Add row</button>\n<SvGrid data={rows} columns={staffColumns} />\n```\n\n### Identity\n\nToday the wrapper uses each row's array index as its id. That's fine\nfor read-only views; if you mutate `rows`, prefer keeping the same\nobject references for rows that didn't change so selection and edit\nstate line up. A `getRowId` prop on the wrapper is tracked in\n[Missing features](../help/missing-features.md) and supported by the\nheadless `createSvGrid` core today.\n\n### Immutability\n\nSvGrid does **not** mutate your data. When the user commits an edit the\ngrid emits an `onCellValueChange` event; you decide whether to mutate\nin place or copy. See [Saving values](../help/editing/saving-values.md).\n\n## Column definitions\n\nA column definition tells SvGrid how to read a value out of a row, how\nto render it, and how to format it.\n\n```ts\nimport type { ColumnDef } from '@svgrid/grid'\n\ntype Person = {\n id: string\n firstName: string\n lastName: string\n age: number\n joinedAt: string // ISO date\n salary: number\n active: boolean\n}\n\nconst columns: GridColumns<Person> = [\n // `field`: read a key straight off the row\n { field: 'firstName', header: 'First name' },\n\n // `fieldFn`: compute the value; needs an explicit `id`\n {\n id: 'fullName',\n header: 'Full name',\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\n },\n\n // Numeric, with locale-aware formatting\n {\n field: 'age',\n header: 'Age',\n format: { type: 'number', options: { maximumFractionDigits: 0 } },\n },\n\n // Date with explicit pattern\n {\n field: 'joinedAt',\n header: 'Joined',\n format: { type: 'date', pattern: 'y-m-d' },\n },\n\n // Currency\n {\n field: 'salary',\n header: 'Salary',\n format: { type: 'currency', currency: 'USD' },\n },\n\n // Boolean rendered as a checkbox\n {\n field: 'active',\n header: 'Active',\n editorType: 'checkbox',\n },\n]\n```\n\n### Common properties\n\n| Property | Purpose |\n| ------------- | -------------------------------------------------------------------------------------------------------- |\n| `field` | Reads `row[key]`. |\n| `fieldFn` | Computes the value from the row. Required when there's no underlying field. |\n| `id` | Stable column id. Required when you use `fieldFn` (no field to derive from). |\n| `header` | String or render snippet for the header. |\n| `footer` | String or render snippet for the footer row. |\n| `cell` | Render snippet / component for the body cell. See [Cell components](../help/cells/cell-components.md). |\n| `format` | Locale-aware formatter: `number`, `currency`, `percent`, `date`, `datetime`. |\n| `formatter` | Function form for one-off custom value formatting. |\n| `editorType` | Inline editor: `text` \\| `number` \\| `checkbox` \\| `date` \\| `datetime`. |\n| `width` | Initial column width in pixels. Default comes from the `columnWidth` prop on `<SvGrid>`. |\n| `align` | Header + body alignment: `'left'` \\| `'right'` \\| `'center'`. Inferred from `editorType` when omitted. |\n| `columns` | Child column defs for column groups (multi-level header). |\n\nSorting / filtering / grouping are toggled per-grid via the registered\nfeatures. Per-column flags (`enableSorting: false`, etc.) are on the\n[Missing features](../help/missing-features.md) list.\n\n### Custom cells\n\nFor anything beyond a stringified value, render with `renderSnippet`:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, renderSnippet, type GridColumns } from '@svgrid/grid'\n\n type Named = { firstName: string; lastName: string; age: number }\n\n const named: Named[] = [\n { firstName: 'Ada', lastName: 'Lovelace', age: 36 },\n { firstName: 'Grace', lastName: 'Hopper', age: 45 },\n { firstName: 'Linus', lastName: 'Torvalds', age: 54 },\n ]\n</script>\n\n{#snippet PersonCell(props: { row: Named })}\n <span class=\"inline-flex items-center gap-2\">\n <span class=\"initials\">{props.row.firstName[0]}{props.row.lastName[0]}</span>\n <span>{props.row.firstName} {props.row.lastName}</span>\n </span>\n{/snippet}\n\n<SvGrid\n data={named}\n columns={[\n {\n id: 'person',\n header: 'Person',\n fieldFn: (r) => `${r.firstName} ${r.lastName}`,\n cell: (ctx) => renderSnippet(PersonCell, { row: ctx.row.original }),\n },\n { field: 'age', header: 'Age' },\n ] satisfies GridColumns<Named>}\n/>\n```\n\n[Cell components](../help/cells/cell-components.md) has the full\npatterns: avatars, sparklines, progress bars, status badges.\n\n## Formats on the columns you already have\n\n`format` is declarative and locale-aware, so a currency, a percentage and a\ndate all come from the same place rather than from three helper functions.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n type Row = { product: string; price: number; margin: number; shipped: string }\n\n const rows: Row[] = [\n { product: 'Cycling cap', price: 29, margin: 0.42, shipped: '2026-05-02' },\n { product: 'Patch kit', price: 12.8, margin: 0.31, shipped: '2026-05-11' },\n { product: 'Road bottle', price: 8.5, margin: 0.55, shipped: '2026-05-19' },\n ]\n\n const columns: GridColumns<Row> = [\n { field: 'product', header: 'Product', width: 170 },\n { field: 'price', header: 'Price', width: 120,\n format: { type: 'currency', currency: 'USD' } },\n { field: 'margin', header: 'Margin', width: 110,\n format: { type: 'percent' } },\n { field: 'shipped', header: 'Shipped', width: 150, cellDataType: 'dateString',\n format: { type: 'date', options: { dateStyle: 'medium' } } },\n ]\n</script>\n\n<SvGrid data={rows} {columns} sortable />\n```\n"
3936
+ "markdown": "# 3. Data and columns\n\n> Step 3 of 6 · [← First grid](./2-first-grid.md) · [Next: Features →](./4-features.md)\n\nSvGrid reads two arrays: `data` (your rows) and `columns` (the\ncolumn-definition list). Everything else is opt-in.\n\n![The data array of rows and the columns array of definitions both feed the SvGrid element, with a ColumnDef anatomy callout mapping field to cell value, header to column label, and cell or renderSnippet to a custom render.](/docs-media/gs-data-columns.svg)\n\n## Row data\n\nThe `data` prop is any `ReadonlyArray<TRow>`. A Svelte 5 `$state`\narray, a derived store, an SWR/TanStack-Query cache, a `+page.ts` load\nresult, a plain literal - the grid doesn't care, as long as the array\nreference changes when the rows change.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Staff = { id: string; firstName: string; age: number }\n\n // Reactive: pushing into `rows` updates the grid automatically.\n let rows = $state<Staff[]>([\n { id: '1', firstName: 'Ada', age: 36 },\n { id: '2', firstName: 'Linus', age: 54 },\n ])\n\n const staffColumns: GridColumns<Staff> = [\n { field: 'firstName', header: 'First name', width: 180 },\n { field: 'age', header: 'Age', width: 90 },\n ]\n\n function addRow() {\n rows.push({ id: crypto.randomUUID(), firstName: 'New', age: 0 })\n }\n</script>\n\n<button onclick={addRow}>Add row</button>\n<SvGrid data={rows} columns={staffColumns} />\n```\n\n### Identity\n\nToday the wrapper uses each row's array index as its id. That's fine\nfor read-only views; if you mutate `rows`, prefer keeping the same\nobject references for rows that didn't change so selection and edit\nstate line up. A `getRowId` prop on the wrapper is tracked in\n[Missing features](../help/missing-features.md) and supported by the\nheadless `createSvGrid` core today.\n\n### Immutability\n\nSvGrid does **not** mutate your data. When the user commits an edit the\ngrid emits an `onCellValueChange` event; you decide whether to mutate\nin place or copy. See [Saving values](../help/editing/saving-values.md).\n\n## Column definitions\n\nA column definition tells SvGrid how to read a value out of a row, how\nto render it, and how to format it.\n\n```ts\nimport type { ColumnDef } from '@svgrid/grid'\n\ntype Person = {\n id: string\n firstName: string\n lastName: string\n age: number\n joinedAt: string // ISO date\n salary: number\n active: boolean\n}\n\nconst columns: GridColumns<Person> = [\n // `field`: read a key straight off the row\n { field: 'firstName', header: 'First name' },\n\n // `fieldFn`: compute the value; needs an explicit `id`\n {\n id: 'fullName',\n header: 'Full name',\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\n },\n\n // Numeric, with locale-aware formatting\n {\n field: 'age',\n header: 'Age',\n format: { type: 'number', options: { maximumFractionDigits: 0 } },\n },\n\n // Date with explicit pattern\n {\n field: 'joinedAt',\n header: 'Joined',\n format: { type: 'date', pattern: 'y-m-d' },\n },\n\n // Currency\n {\n field: 'salary',\n header: 'Salary',\n format: { type: 'currency', currency: 'USD' },\n },\n\n // Boolean rendered as a checkbox\n {\n field: 'active',\n header: 'Active',\n editorType: 'checkbox',\n },\n]\n```\n\n### Common properties\n\n| Property | Purpose |\n| ------------- | -------------------------------------------------------------------------------------------------------- |\n| `field` | Reads `row[key]`. |\n| `fieldFn` | Computes the value from the row. Required when there's no underlying field. |\n| `id` | Stable column id. Required when you use `fieldFn` (no field to derive from). |\n| `header` | String or render snippet for the header. |\n| `footer` | String or render snippet for the footer row. |\n| `cell` | Render snippet / component for the body cell. See [Cell components](../help/cells/cell-components.md). |\n| `format` | Locale-aware formatter: `number`, `currency`, `percent`, `date`, `datetime`. |\n| `formatter` | Function form for one-off custom value formatting. |\n| `editorType` | Inline editor: `text` \\| `number` \\| `checkbox` \\| `date` \\| `datetime`. |\n| `width` | Initial column width in pixels. Default comes from the `columnWidth` prop on `<SvGrid>`. |\n| `align` | Header + body alignment: `'left'` \\| `'right'` \\| `'center'`. Inferred from `editorType` when omitted. |\n| `columns` | Child column defs for column groups (multi-level header). |\n\nSorting / filtering / grouping are toggled per-grid via the registered\nfeatures. Per-column flags (`enableSorting: false`, etc.) are on the\n[Missing features](../help/missing-features.md) list.\n\n### Custom cells\n\nFor anything beyond a stringified value, render with `renderSnippet`:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, renderSnippet, type GridColumns } from '@svgrid/grid'\n\n type Named = { firstName: string; lastName: string; age: number }\n\n const named: Named[] = [\n { firstName: 'Ada', lastName: 'Lovelace', age: 36 },\n { firstName: 'Grace', lastName: 'Hopper', age: 45 },\n { firstName: 'Linus', lastName: 'Torvalds', age: 54 },\n ]\n</script>\n\n{#snippet PersonCell(props: { row: Named })}\n <span class=\"inline-flex items-center gap-2\">\n <span class=\"initials\">{props.row.firstName[0]}{props.row.lastName[0]}</span>\n <span>{props.row.firstName} {props.row.lastName}</span>\n </span>\n{/snippet}\n\n<SvGrid\n data={named}\n columns={[\n {\n id: 'person',\n header: 'Person',\n fieldFn: (r) => `${r.firstName} ${r.lastName}`,\n cell: (ctx) => renderSnippet(PersonCell, { row: ctx.row.original }),\n },\n { field: 'age', header: 'Age' },\n ] satisfies GridColumns<Named>}\n/>\n```\n\n[Cell components](../help/cells/cell-components.md) has the full\npatterns: avatars, sparklines, progress bars, status badges.\n\n## Formats on the columns you already have\n\n`format` is declarative and locale-aware, so a currency, a percentage and a\ndate all come from the same place rather than from three helper functions.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n type Row = { product: string; price: number; margin: number; shipped: string }\n\n const rows: Row[] = [\n { product: 'Cycling cap', price: 29, margin: 0.42, shipped: '2026-05-02' },\n { product: 'Patch kit', price: 12.8, margin: 0.31, shipped: '2026-05-11' },\n { product: 'Road bottle', price: 8.5, margin: 0.55, shipped: '2026-05-19' },\n ]\n\n const columns: GridColumns<Row> = [\n { field: 'product', header: 'Product', width: 170 },\n { field: 'price', header: 'Price', width: 120,\n format: { type: 'currency', currency: 'USD' } },\n { field: 'margin', header: 'Margin', width: 110,\n format: { type: 'percent' } },\n { field: 'shipped', header: 'Shipped', width: 150, cellDataType: 'dateString',\n format: { type: 'date', options: { dateStyle: 'medium' } } },\n ]\n</script>\n\n<SvGrid data={rows} {columns} sortable />\n```\n\n<!-- tutorial:learn-2-data-and-columns -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-2-data-and-columns\" data-docs-tutorial=\"learn-2-data-and-columns\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-2-data-and-columns.mp4\" poster=\"/tutorials/learn-2-data-and-columns.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 2: your data, your columns, 88 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-2-data-and-columns.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-2-data-and-columns.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 2: your data, your columns</strong> (88 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>In lesson one the rows were made up. In this one they are yours, they stay reactive, and the columns start looking like a real application.</p>\n<p>Start from your own row type. The grid never asks you to convert anything: whatever shape your records have, that is the shape it takes. Declare the array with the state rune and it stays reactive.</p>\n<p>Columns map a field to a header, and width fixes one that would otherwise stretch. Then a plain function that pushes a record onto the array.</p>\n<p>Watch what happens when that button runs. Pushing onto the array is all it does, and the grid follows, because the rows are a rune and not a copy the grid took at startup.</p>\n<p>Now formatting. This is the same page of the docs, a few blocks further down, and it is the thing people most often do by hand and regret.</p>\n<p>A currency format, a percent, a date. Each one renders the value for the reader and leaves your data alone, which matters because sorting and exporting both use the raw number underneath, not the string on screen.</p>\n<p>One habit worth forming: type the column array as GridColumns of your row type. The compiler then checks every field name against the row, so a typo is a build error instead of an empty column nobody notices.</p>\n<p>Both files are on the page below, and they run there too. See you in lesson three.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-2-data-and-columns -->\n"
3937
3937
  },
3938
3938
  {
3939
3939
  "slug": "getting-started/4-features",
3940
3940
  "path": "docs/getting-started/4-features.md",
3941
3941
  "title": "4. Features",
3942
3942
  "section": "Getting started",
3943
- "markdown": "# 4. Features\n\n> Step 4 of 6 · [← Data and columns](./3-data-and-columns.md) · [Next: Theme and density →](./5-theme-and-density.md)\n\nEvery capability is **off by default** - a bare `<SvGrid>` is a plain,\nread-only table. Opt in with boolean props. No imports, no feature\nconstants:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, tableFeatures, rowSortingFeature, columnFilteringFeature } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const data = people\n\n let rows = $state<Person[]>(people)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<SvGrid\n data={rows}\n columns={columns}\n sortable\n filterable\n editable\n groupable\n pageable\n/>\n```\n\n`selectable` completes the set - see\n[the exception](#the-exception-selection-is-already-on) below, since selection\nis already on:\n\n```svelte {runnable}\n<SvGrid data={rows} columns={columns} selectable={false} />\n```\n\n| Shortcut | Turns on | Equivalent to |\n| ------------ | ------------------------------------------------ | ------------------------------------------ |\n| `sortable` | Click headers to sort | injects `rowSortingFeature` |\n| `filterable` | Per-column filter menu | injects `columnFilteringFeature` |\n| `editable` | Inline cell editing (text editor unless `editorType`) | `enableInlineEditing` |\n| `selectable` | Cell selection (click a cell, drag for a range) | `enableCellSelection` |\n| `groupable` | \"Group by this column\" in the column menu | `showGroupingControls` |\n| `pageable` | Pagination footer | `showPagination` |\n\nEach shortcut is an override: omit it (or set `false`) to leave the\ncapability off; set it `true` to opt in. See the live\n[Shortcut config](https://svgrid.com/demos/135-shortcut-config/) demo.\n\n### The exception: selection is already on\n\n`selectable` is the one shortcut that starts **on**. Selection is governed by\n`selectionMode`, which defaults to `'both'`, so a bare grid already has both\nsurfaces:\n\n| Surface | Controlled by | Default |\n| ------------------------ | ----------------------------------- | ------- |\n| Cell / range selection | `selectable` -> `enableCellSelection` | **on** |\n| Selection checkbox column | `showRowSelection` | **on** |\n\nNeither needs a feature import. `selectable` is listed with the shortcuts so\nthe capability has a name next to the others, and so `selectable={false}` is a\none-word way to switch cell selection off. To move both surfaces at once, use\n`selectionMode=\"none\"` (or `\"row\"` / `\"cell\"`).\n\n**For most grids this is the whole story** - skip to\n[Theme and density](./5-theme-and-density.md). The rest of this page is the\nexplicit form underneath, worth reading when you want finer control.\n\n## The explicit form: registering features\n\nThe engine is feature-gated: out of the box you get the core row model (rows\nin their original order), and each capability is a feature you register. The\nshortcut props above just inject these for you.\n\n![The engine ships the core row model in original order; you opt into features that wire into the SvGrid component.](/docs-media/grid-feature-gating.svg)\n\nRegister them yourself when you need the fine-grained props alongside -\n`filterMode`, `pageSize`, per-column `sortable: false`:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n type GridColumns,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showPagination={true}\n pageSize={25}\n/>\n```\n\nIf you need the headless pipeline directly - say, a custom renderer - drop\ndown to `createSvGrid` from the same package. See\n[Why headless?](../why-headless.md).\n\n## The feature catalogue\n\n| Feature | What it enables | Doc |\n| ------------------------ | --------------------------------------------------------- | -------------------------------------------------- |\n| `rowSortingFeature` | Click headers to sort; Shift-click for multi-sort. | [Row sorting](../help/rows/row-sorting.md) |\n| `columnFilteringFeature` | Per-column filter menu + filter row + global search. | [Filter overview](../help/filtering/overview.md) |\n| `rowPaginationFeature` | Page slicing + footer with page-size selector. | [Row pagination](../help/rows/row-pagination.md) |\n| `rowSelectionFeature` | Checkbox column + Shift / Ctrl multi-select. | [Row selection](../help/rows/styling-rows.md) |\n| `columnGroupingFeature` | Group-by-column + aggregated footer summaries. | [Grouping & aggregation](../help/grouping-aggregation.md) |\n| `rowExpandingFeature` | Tree / master-detail expand-collapse. | [Tree rows](../help/rows/tree-rows.md) |\n\n**Rule of thumb:** only register the features you use. Each one ships\nabout 1-2 KB gzipped and adds a small per-update cost.\n\n## The three operating modes\n\nThere's one decision per dimension (sort, filter): **uncontrolled**\n(default), **observable** (callbacks), or **external** (you own the row\nordering).\n\n### Uncontrolled (the default)\n\nThe wrapper owns the state. Pass the start config via props:\n\n```svelte {runnable}\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination={true}\n pageSize={50}\n filterMode=\"menu\"\n/>\n```\n\n### Observable - callbacks fire on every change\n\nThe wrapper still owns the state, but emits callbacks. Use this when\nsomething outside the grid needs to react (a \"X rows selected\" pill,\nURL sync, a server fetch).\n\n```svelte {runnable}\n<script lang=\"ts\">\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n onSortingChange={(next) => (sorting = next)}\n onFiltersChange={(next) => (filters = next.columns)}\n/>\n```\n\n### External - you own the row ordering\n\nFor server-side data or tree data the wrapper records the sort + filter\nUI state but does **not** re-order the rows - you do. See\n[Going to production §1](./6-going-to-production.md#1-server-side-data).\n\n## Selection + editing\n\nBoth are off by default. Two top-level umbrella props turn them on:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n selectionMode=\"both\"\n showRowSelection={true}\n enableInlineEditing={true}\n enableCellSelection={true}\n onRowSelectionChange={(state, selectedRows) => /* … */}\n onCellValueChange={(event) => /* event: { rowIndex, columnId, oldValue, newValue, row } */}\n/>\n```\n\n`selectionMode` choices:\n\n- `'row'` - checkbox column only\n- `'cell'` - click-and-drag range selection only\n- `'both'` - both (default)\n- `'none'` - both off\n\nCell editing falls back to a text editor, so `editable` works on plain\ncolumns; declare `editorType` on a column to get the right control for its\ntype (`number`, `date`, `checkbox`, `list`, ...).\nSee [Editing overview](../help/editing/overview.md).\n\n## Keyboard map\n\n| Action | Keys |\n| --------------------- | ------------------------------------- |\n| Move active cell | Arrow keys |\n| First / last in row | Home / End |\n| First / last in grid | Ctrl + Home / Ctrl + End |\n| Move by viewport | Page Up / Page Down |\n| Extend range | Shift + arrows / Shift + Home / End |\n| Start editing | Enter, F2, or double-click |\n| Commit edit | Enter, Tab |\n| Cancel edit | Esc |\n| Toggle row selection | Space |\n| Copy / paste range | Ctrl/Cmd + C / V (TSV) |\n\nThe full a11y model is in [Accessibility](../help/accessibility.md).\n"
3943
+ "markdown": "# 4. Features\n\n> Step 4 of 6 · [← Data and columns](./3-data-and-columns.md) · [Next: Theme and density →](./5-theme-and-density.md)\n\nEvery capability is **off by default** - a bare `<SvGrid>` is a plain,\nread-only table. Opt in with boolean props. No imports, no feature\nconstants:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, tableFeatures, rowSortingFeature, columnFilteringFeature } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const data = people\n\n let rows = $state<Person[]>(people)\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<SvGrid\n data={rows}\n columns={columns}\n sortable\n filterable\n editable\n groupable\n pageable\n/>\n```\n\n`selectable` completes the set - see\n[the exception](#the-exception-selection-is-already-on) below, since selection\nis already on:\n\n```svelte {runnable}\n<SvGrid data={rows} columns={columns} selectable={false} />\n```\n\n| Shortcut | Turns on | Equivalent to |\n| ------------ | ------------------------------------------------ | ------------------------------------------ |\n| `sortable` | Click headers to sort | injects `rowSortingFeature` |\n| `filterable` | Per-column filter menu | injects `columnFilteringFeature` |\n| `editable` | Inline cell editing (text editor unless `editorType`) | `enableInlineEditing` |\n| `selectable` | Cell selection (click a cell, drag for a range) | `enableCellSelection` |\n| `groupable` | \"Group by this column\" in the column menu | `showGroupingControls` |\n| `pageable` | Pagination footer | `showPagination` |\n\nEach shortcut is an override: omit it (or set `false`) to leave the\ncapability off; set it `true` to opt in. See the live\n[Shortcut config](https://svgrid.com/demos/135-shortcut-config/) demo.\n\n### The exception: selection is already on\n\n`selectable` is the one shortcut that starts **on**. Selection is governed by\n`selectionMode`, which defaults to `'both'`, so a bare grid already has both\nsurfaces:\n\n| Surface | Controlled by | Default |\n| ------------------------ | ----------------------------------- | ------- |\n| Cell / range selection | `selectable` -> `enableCellSelection` | **on** |\n| Selection checkbox column | `showRowSelection` | **on** |\n\nNeither needs a feature import. `selectable` is listed with the shortcuts so\nthe capability has a name next to the others, and so `selectable={false}` is a\none-word way to switch cell selection off. To move both surfaces at once, use\n`selectionMode=\"none\"` (or `\"row\"` / `\"cell\"`).\n\n**For most grids this is the whole story** - skip to\n[Theme and density](./5-theme-and-density.md). The rest of this page is the\nexplicit form underneath, worth reading when you want finer control.\n\n## The explicit form: registering features\n\nThe engine is feature-gated: out of the box you get the core row model (rows\nin their original order), and each capability is a feature you register. The\nshortcut props above just inject these for you.\n\n![The engine ships the core row model in original order; you opt into features that wire into the SvGrid component.](/docs-media/grid-feature-gating.svg)\n\nRegister them yourself when you need the fine-grained props alongside -\n`filterMode`, `pageSize`, per-column `sortable: false`:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n type GridColumns,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showPagination={true}\n pageSize={25}\n/>\n```\n\nIf you need the headless pipeline directly - say, a custom renderer - drop\ndown to `createSvGrid` from the same package. See\n[Why headless?](../why-headless.md).\n\n## The feature catalogue\n\n| Feature | What it enables | Doc |\n| ------------------------ | --------------------------------------------------------- | -------------------------------------------------- |\n| `rowSortingFeature` | Click headers to sort; Shift-click for multi-sort. | [Row sorting](../help/rows/row-sorting.md) |\n| `columnFilteringFeature` | Per-column filter menu + filter row + global search. | [Filter overview](../help/filtering/overview.md) |\n| `rowPaginationFeature` | Page slicing + footer with page-size selector. | [Row pagination](../help/rows/row-pagination.md) |\n| `rowSelectionFeature` | Checkbox column + Shift / Ctrl multi-select. | [Row selection](../help/rows/styling-rows.md) |\n| `columnGroupingFeature` | Group-by-column + aggregated footer summaries. | [Grouping & aggregation](../help/grouping-aggregation.md) |\n| `rowExpandingFeature` | Tree / master-detail expand-collapse. | [Tree rows](../help/rows/tree-rows.md) |\n\n**Rule of thumb:** only register the features you use. Each one ships\nabout 1-2 KB gzipped and adds a small per-update cost.\n\n## The three operating modes\n\nThere's one decision per dimension (sort, filter): **uncontrolled**\n(default), **observable** (callbacks), or **external** (you own the row\nordering).\n\n### Uncontrolled (the default)\n\nThe wrapper owns the state. Pass the start config via props:\n\n```svelte {runnable}\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination={true}\n pageSize={50}\n filterMode=\"menu\"\n/>\n```\n\n### Observable - callbacks fire on every change\n\nThe wrapper still owns the state, but emits callbacks. Use this when\nsomething outside the grid needs to react (a \"X rows selected\" pill,\nURL sync, a server fetch).\n\n```svelte {runnable}\n<script lang=\"ts\">\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n onSortingChange={(next) => (sorting = next)}\n onFiltersChange={(next) => (filters = next.columns)}\n/>\n```\n\n### External - you own the row ordering\n\nFor server-side data or tree data the wrapper records the sort + filter\nUI state but does **not** re-order the rows - you do. See\n[Going to production §1](./6-going-to-production.md#1-server-side-data).\n\n## Selection + editing\n\nBoth are off by default. Two top-level umbrella props turn them on:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n selectionMode=\"both\"\n showRowSelection={true}\n enableInlineEditing={true}\n enableCellSelection={true}\n onRowSelectionChange={(state, selectedRows) => /* … */}\n onCellValueChange={(event) => /* event: { rowIndex, columnId, oldValue, newValue, row } */}\n/>\n```\n\n`selectionMode` choices:\n\n- `'row'` - checkbox column only\n- `'cell'` - click-and-drag range selection only\n- `'both'` - both (default)\n- `'none'` - both off\n\nCell editing falls back to a text editor, so `editable` works on plain\ncolumns; declare `editorType` on a column to get the right control for its\ntype (`number`, `date`, `checkbox`, `list`, ...).\nSee [Editing overview](../help/editing/overview.md).\n\n## Keyboard map\n\n| Action | Keys |\n| --------------------- | ------------------------------------- |\n| Move active cell | Arrow keys |\n| First / last in row | Home / End |\n| First / last in grid | Ctrl + Home / Ctrl + End |\n| Move by viewport | Page Up / Page Down |\n| Extend range | Shift + arrows / Shift + Home / End |\n| Start editing | Enter, F2, or double-click |\n| Commit edit | Enter, Tab |\n| Cancel edit | Esc |\n| Toggle row selection | Space |\n| Copy / paste range | Ctrl/Cmd + C / V (TSV) |\n\nThe full a11y model is in [Accessibility](../help/accessibility.md).\n\n<!-- tutorial:learn-3-sort-filter-page -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-3-sort-filter-page\" data-docs-tutorial=\"learn-3-sort-filter-page\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-3-sort-filter-page.mp4\" poster=\"/tutorials/learn-3-sort-filter-page.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 3: sorting, filtering and paging, 143 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-3-sort-filter-page.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-3-sort-filter-page.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 3: sorting, filtering and paging</strong> (143 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Your grid renders. It does not sort, filter or page yet, and that is deliberate. This lesson turns those on and explains the trade the library is making for you.</p>\n<p>Here is a wider row type and a realistic set of records, the kind of table you would actually ship. Nothing new yet.</p>\n<p>Columns, with a currency format on salary from the last lesson.</p>\n<p>And now the part that matters. Sortable, filterable, editable, groupable, pageable. Each is one boolean, and each one pulls in only the code that feature needs, which is why a grid you never configured stays small.</p>\n<p>That is the whole file: the row type, the records, the columns, and the component with those five words on it. Nothing else is configured, and nothing is hidden above the fold.</p>\n<p>Click a header and it sorts. Click again for descending, and a third time to clear it and get your original order back.</p>\n<p>Shift-click a second header to sort by two columns at once: department first, then name inside each department.</p>\n<p>The funnel next to each header is filtering, switched on by that same one word. How it looks is a choice, and the next screen has the version most people want.</p>\n<p>Five rows will not show you paging, so here is the same set of props over five thousand. Filterable on its own gives you a menu per header; filter mode row puts an input under each header instead, and it narrows the table as you type.</p>\n<p>And the pager cuts what is left into pages, with the range and the count kept honest as the filter changes them. None of this needed a callback from you.</p>\n<p>Those five booleans are a shorthand. When you need finer control, register the features explicitly with tableFeatures and pass them in, which is the same machinery with the wiring exposed; the docs page below shows both forms side by side. Lesson four lets people change the table.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-3-sort-filter-page -->\n"
3944
3944
  },
3945
3945
  {
3946
3946
  "slug": "getting-started/5-theme-and-density",
3947
3947
  "path": "docs/getting-started/5-theme-and-density.md",
3948
3948
  "title": "5. Theme and density",
3949
3949
  "section": "Getting started",
3950
- "markdown": "# 5. Theme and density\n\n> Step 5 of 6 · [← Features](./4-features.md) · [Next: Going to production →](./6-going-to-production.md)\n\n## Start with a preset\n\nBefore hand-writing any tokens: 20 design-system presets ship with the package,\neach a single stylesheet with a full light + dark palette. One import re-themes\nthe whole grid.\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nimport '@svgrid/grid/themes/shadcn.css'\n```\n\nAvailable: `ember` (SvGrid's own look), `shadcn`, `tailwind`, `material`,\n`fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`, `salesforce`, `sap`,\n`github`, `linear`, `notion`, `vercel`, `excel`, `nord`, `dracula`,\n`catppuccin`, `ag-alpine`.\n\nEach preset defines every token the grid and the UI components read - including\nthe semantic status colors (`--sg-danger`, `--sg-success`, `--sg-warning`,\n`--sg-info`) and the focus ring, which follows the preset's accent. Presets\nflip with `data-theme=\"dark\"` automatically (see below).\n\nOverride individual tokens after the import to adjust a preset, or skip presets\nentirely and declare the tokens yourself - that is the rest of this page.\n\n## Declaring tokens yourself\n\nThe render component (`<SvGrid>`) ships its own scoped styles. You\nre-theme it by declaring `--sg-*` CSS custom properties at any level\nabove the grid - `:root` for the whole app, a wrapper `<div>` for one\ninstance, or directly on the `<SvGrid>` element itself.\n\n![The --sg-* CSS custom properties declared at :root, a wrapper div, or the SvGrid element cascade into the grid, controlling light and dark themes and comfortable versus compact row density.](/docs-media/gs-theming.svg)\n\n## Token surface\n\nThe 20-odd tokens the renderer reads:\n\n| Token | What it paints |\n| -------------------------------- | ------------------------------------------- |\n| `--sg-bg` | Cell background |\n| `--sg-fg` | Cell text |\n| `--sg-muted` | Secondary text (footers, subtitles) |\n| `--sg-border` | Cell + header borders |\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\n| `--sg-row-alt-bg` | Zebra rows |\n| `--sg-row-hover-bg` | Row + cell hover |\n| `--sg-selection-bg` | Selected cell / row tint |\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\n| `--sg-focus-ring` | Keyboard focus outline |\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\n| `--sg-pill-active` / `-fg` | \"Active\" status pills |\n| `--sg-pill-pending` / `-fg` | \"Pending\" status pills |\n| `--sg-pill-inactive` / `-fg` | \"Inactive\" status pills |\n| `--sg-scrollbar-*` (10 tokens) | Custom-painted scrollbars |\n\n## Light + dark via `data-theme`\n\nThe gallery flips themes by writing `dark` or `light` to\n`html[data-theme]`. Every token redeclares under that selector:\n\n```css\n:root {\n --sg-bg: #ffffff;\n --sg-fg: #0f172a;\n --sg-border: #e2e8f0;\n --sg-header-bg: #f1f5f9;\n --sg-row-alt-bg: #f8fafc;\n --sg-row-hover-bg: #eef2ff;\n --sg-accent: #2563eb;\n}\n\nhtml[data-theme='dark'] {\n --sg-bg: #0f172a;\n --sg-fg: #f1f5f9;\n --sg-border: #334155;\n --sg-header-bg: #1e2433;\n --sg-row-alt-bg: #1b2230;\n --sg-row-hover-bg: #232b3c;\n --sg-accent: #3b82f6;\n color-scheme: dark;\n}\n```\n\nToggling is one line in the app shell:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let theme = $state<'light' | 'dark'>('dark')\n $effect(() => document.documentElement.setAttribute('data-theme', theme))\n</script>\n\n<button onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}>\n Toggle theme\n</button>\n```\n\n## Per-instance theming\n\nBecause the tokens are plain custom properties they cascade. To style a\nsingle grid, wrap it in a `<div>` that sets its own values:\n\n```svelte\n<div style=\"--sg-bg: #fff8f0; --sg-accent: #db2777;\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\nThe [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte)\ndemo applies three full palettes (light / dark / high-contrast) this way.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"520\"></div>\n\n## Density\n\nDensity is a **prop, not a token**. The virtualizer has to know each\nrow's height as a number before it can position rows, so it cannot be\nresolved from CSS - the grid writes the height as an inline style on\nevery row, which would override a stylesheet rule anyway.\n\nSet `rowHeight` on `<SvGrid>`. Numeric, in pixels; drives the row\nheight and the active-cell hit box. The default is `30`.\n\n```svelte\n<SvGrid {data} {columns} features={features} rowHeight={28} />\n```\n\nCommon steps: `28` compact, `30` normal, `46`-`48` comfortable. For\nrows whose height depends on their content, set `autoRowHeight`\ninstead and the grid measures each row.\n\nA user-facing \"density selector\" is half a dozen lines:\n\n```svelte\n<script lang=\"ts\">\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\n const height = $derived(\n density === 'compact' ? 28 : density === 'comfortable' ? 48 : 30,\n )\n</script>\n\n<select bind:value={density}>\n <option value=\"compact\">Compact</option>\n <option value=\"normal\">Normal</option>\n <option value=\"comfortable\">Comfortable</option>\n</select>\n\n<SvGrid {data} {columns} features={features} rowHeight={height} />\n```\n\n## Sizing the grid\n\nThe wrapper renders inside whatever container you give it. The\n`containerHeight` prop sets the scrollable shell height:\n\n```svelte\n<!-- Numeric: px -->\n<SvGrid {data} {columns} features={features} containerHeight={520} />\n\n<!-- String: passed through to CSS -->\n<SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n<SvGrid {data} {columns} features={features} containerHeight=\"auto\" />\n```\n\nFor a flex-grow layout the canonical recipe is:\n\n```svelte\n<div class=\"flex flex-col h-screen\">\n <header>…</header>\n <div class=\"flex-1 min-h-0\">\n <SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n </div>\n</div>\n```\n\nThe `min-h-0` is the bit that bites. Flex children default to\n`min-height: auto`, which prevents the inner scroll container from\nshrinking, which makes the whole page scroll instead of the grid.\n\n## Full Tailwind integration\n\nIf your app uses Tailwind, see [Tailwind integration](../help/tailwind.md)\nfor: install + PostCSS config, `@custom-variant` so the `dark:`\nmodifier follows `data-theme`, the override hooks for the stable\n`.sv-grid-*` class names, and the anti-patterns (don't `@apply` inside\ngrid selectors, don't put utility classes on grid children, don't\nfight column widths in CSS).\n\n## Try it\n\nThere is no `density` prop - row height is the density control, and it is one\nnumber. Compact for a screen someone scans all day, comfortable for one they\nread.\n\n```svelte {runnable}\n<SvGrid data={people} {columns} rowHeight={28} showRowNumbers />\n\n<SvGrid data={people} {columns} rowHeight={48} showRowNumbers />\n```\n\n## Re-theming with tokens\n\nThe grid reads `--sg-*` custom properties, so a theme is a block of variables on\nany ancestor - no theme file, no build step.\n\n```svelte {runnable}\n<div class=\"midnight\">\n <SvGrid data={people} {columns} sortable />\n</div>\n\n<style>\n .midnight {\n --sg-bg: #0f172a;\n --sg-fg: #e2e8f0;\n --sg-border: #1e293b;\n --sg-header-bg: #111c33;\n --sg-header-fg: #93c5fd;\n --sg-accent: #38bdf8;\n --sg-row-hover: #16233d;\n }\n</style>\n```\n"
3950
+ "markdown": "# 5. Theme and density\n\n> Step 5 of 6 · [← Features](./4-features.md) · [Next: Going to production →](./6-going-to-production.md)\n\n## Start with a preset\n\nBefore hand-writing any tokens: 20 design-system presets ship with the package,\neach a single stylesheet with a full light + dark palette. One import re-themes\nthe whole grid.\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nimport '@svgrid/grid/themes/shadcn.css'\n```\n\nAvailable: `ember` (SvGrid's own look), `shadcn`, `tailwind`, `material`,\n`fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`, `salesforce`, `sap`,\n`github`, `linear`, `notion`, `vercel`, `excel`, `nord`, `dracula`,\n`catppuccin`, `ag-alpine`.\n\nEach preset defines every token the grid and the UI components read - including\nthe semantic status colors (`--sg-danger`, `--sg-success`, `--sg-warning`,\n`--sg-info`) and the focus ring, which follows the preset's accent. Presets\nflip with `data-theme=\"dark\"` automatically (see below).\n\nOverride individual tokens after the import to adjust a preset, or skip presets\nentirely and declare the tokens yourself - that is the rest of this page.\n\n## Declaring tokens yourself\n\nThe render component (`<SvGrid>`) ships its own scoped styles. You\nre-theme it by declaring `--sg-*` CSS custom properties at any level\nabove the grid - `:root` for the whole app, a wrapper `<div>` for one\ninstance, or directly on the `<SvGrid>` element itself.\n\n![The --sg-* CSS custom properties declared at :root, a wrapper div, or the SvGrid element cascade into the grid, controlling light and dark themes and comfortable versus compact row density.](/docs-media/gs-theming.svg)\n\n## Token surface\n\nThe 20-odd tokens the renderer reads:\n\n| Token | What it paints |\n| -------------------------------- | ------------------------------------------- |\n| `--sg-bg` | Cell background |\n| `--sg-fg` | Cell text |\n| `--sg-muted` | Secondary text (footers, subtitles) |\n| `--sg-border` | Cell + header borders |\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\n| `--sg-row-alt-bg` | Zebra rows |\n| `--sg-row-hover-bg` | Row + cell hover |\n| `--sg-selection-bg` | Selected cell / row tint |\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\n| `--sg-focus-ring` | Keyboard focus outline |\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\n| `--sg-pill-active` / `-fg` | \"Active\" status pills |\n| `--sg-pill-pending` / `-fg` | \"Pending\" status pills |\n| `--sg-pill-inactive` / `-fg` | \"Inactive\" status pills |\n| `--sg-scrollbar-*` (10 tokens) | Custom-painted scrollbars |\n\n## Light + dark via `data-theme`\n\nThe gallery flips themes by writing `dark` or `light` to\n`html[data-theme]`. Every token redeclares under that selector:\n\n```css\n:root {\n --sg-bg: #ffffff;\n --sg-fg: #0f172a;\n --sg-border: #e2e8f0;\n --sg-header-bg: #f1f5f9;\n --sg-row-alt-bg: #f8fafc;\n --sg-row-hover-bg: #eef2ff;\n --sg-accent: #2563eb;\n}\n\nhtml[data-theme='dark'] {\n --sg-bg: #0f172a;\n --sg-fg: #f1f5f9;\n --sg-border: #334155;\n --sg-header-bg: #1e2433;\n --sg-row-alt-bg: #1b2230;\n --sg-row-hover-bg: #232b3c;\n --sg-accent: #3b82f6;\n color-scheme: dark;\n}\n```\n\nToggling is one line in the app shell:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let theme = $state<'light' | 'dark'>('dark')\n $effect(() => document.documentElement.setAttribute('data-theme', theme))\n</script>\n\n<button onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}>\n Toggle theme\n</button>\n```\n\n## Per-instance theming\n\nBecause the tokens are plain custom properties they cascade. To style a\nsingle grid, wrap it in a `<div>` that sets its own values:\n\n```svelte\n<div style=\"--sg-bg: #fff8f0; --sg-accent: #db2777;\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\nThe [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte)\ndemo applies three full palettes (light / dark / high-contrast) this way.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"520\"></div>\n\n## Density\n\nDensity is a **prop, not a token**. The virtualizer has to know each\nrow's height as a number before it can position rows, so it cannot be\nresolved from CSS - the grid writes the height as an inline style on\nevery row, which would override a stylesheet rule anyway.\n\nSet `rowHeight` on `<SvGrid>`. Numeric, in pixels; drives the row\nheight and the active-cell hit box. The default is `30`.\n\n```svelte\n<SvGrid {data} {columns} features={features} rowHeight={28} />\n```\n\nCommon steps: `28` compact, `30` normal, `46`-`48` comfortable. For\nrows whose height depends on their content, set `autoRowHeight`\ninstead and the grid measures each row.\n\nA user-facing \"density selector\" is half a dozen lines:\n\n```svelte\n<script lang=\"ts\">\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\n const height = $derived(\n density === 'compact' ? 28 : density === 'comfortable' ? 48 : 30,\n )\n</script>\n\n<select bind:value={density}>\n <option value=\"compact\">Compact</option>\n <option value=\"normal\">Normal</option>\n <option value=\"comfortable\">Comfortable</option>\n</select>\n\n<SvGrid {data} {columns} features={features} rowHeight={height} />\n```\n\n## Sizing the grid\n\nThe wrapper renders inside whatever container you give it. The\n`containerHeight` prop sets the scrollable shell height:\n\n```svelte\n<!-- Numeric: px -->\n<SvGrid {data} {columns} features={features} containerHeight={520} />\n\n<!-- String: passed through to CSS -->\n<SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n<SvGrid {data} {columns} features={features} containerHeight=\"auto\" />\n```\n\nFor a flex-grow layout the canonical recipe is:\n\n```svelte\n<div class=\"flex flex-col h-screen\">\n <header>…</header>\n <div class=\"flex-1 min-h-0\">\n <SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n </div>\n</div>\n```\n\nThe `min-h-0` is the bit that bites. Flex children default to\n`min-height: auto`, which prevents the inner scroll container from\nshrinking, which makes the whole page scroll instead of the grid.\n\n## Full Tailwind integration\n\nIf your app uses Tailwind, see [Tailwind integration](../help/tailwind.md)\nfor: install + PostCSS config, `@custom-variant` so the `dark:`\nmodifier follows `data-theme`, the override hooks for the stable\n`.sv-grid-*` class names, and the anti-patterns (don't `@apply` inside\ngrid selectors, don't put utility classes on grid children, don't\nfight column widths in CSS).\n\n## Try it\n\nThere is no `density` prop - row height is the density control, and it is one\nnumber. Compact for a screen someone scans all day, comfortable for one they\nread.\n\n```svelte {runnable}\n<SvGrid data={people} {columns} rowHeight={28} showRowNumbers />\n\n<SvGrid data={people} {columns} rowHeight={48} showRowNumbers />\n```\n\n## Re-theming with tokens\n\nThe grid reads `--sg-*` custom properties, so a theme is a block of variables on\nany ancestor - no theme file, no build step.\n\n```svelte {runnable}\n<div class=\"midnight\">\n <SvGrid data={people} {columns} sortable />\n</div>\n\n<style>\n .midnight {\n --sg-bg: #0f172a;\n --sg-fg: #e2e8f0;\n --sg-border: #1e293b;\n --sg-header-bg: #111c33;\n --sg-header-fg: #93c5fd;\n --sg-accent: #38bdf8;\n --sg-row-hover: #16233d;\n }\n</style>\n```\n\n<!-- tutorial:learn-7-theme -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-7-theme\" data-docs-tutorial=\"learn-7-theme\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-7-theme.mp4\" poster=\"/tutorials/learn-7-theme.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 7: themes, dark mode and density, 113 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-7-theme.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-7-theme.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 7: themes, dark mode and density</strong> (113 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>A grid that looks like a grid library is a grid that never ships. Theming here is CSS custom properties, not utility classes and not a config object.</p>\n<p>Twenty presets ship with the package, and importing one stylesheet is the whole setup. What a preset sets is a list of custom properties, which means you can set them yourself when one is close but not right. Same five people as before.</p>\n<p>A wrapper, and a handful of properties on it. Background, text, border, the header colours, the accent, the hover. They cascade like any other custom property, so this theme is scoped to this one grid rather than the page.</p>\n<p>The whole file, so nothing is hidden: the rows, the columns, a wrapper around the grid, and the properties on that wrapper.</p>\n<p>Here is that file running. Nine declarations, and nothing about the grid was configured to accept them.</p>\n<p>Density is separate from colour, and it is the setting people actually argue about. Row height is a prop, so the compact table and the comfortable one are the same component with one number changed.</p>\n<p>Dark mode is not a second stylesheet you maintain. Every preset declares a light palette and a dark one, and an attribute on the document element picks which is live. Every surface follows, including the parts you never styled.</p>\n<p>The density control here is the same row height prop behind a dropdown, which is what it usually becomes: a preference you save per user rather than a decision you make once for everybody.</p>\n<p>The theme builder on the site edits these tokens live and hands you the CSS at the end. Every property is listed on the page below. See you in lesson eight.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-7-theme -->\n"
3951
3951
  },
3952
3952
  {
3953
3953
  "slug": "getting-started/6-going-to-production",
3954
3954
  "path": "docs/getting-started/6-going-to-production.md",
3955
3955
  "title": "6. Going to production",
3956
3956
  "section": "Getting started",
3957
- "markdown": "# 6. Going to production\n\n> Step 6 of 6 · [← Theme and density](./5-theme-and-density.md)\n\nYou have a working grid. This page is the checklist that turns it into\nsomething you'd ship.\n\n## 1. Server-side data\n\nFor datasets that don't fit in memory, drive the grid from the server.\nPair `externalSort` + `externalFilter` with the corresponding callbacks\nso the grid records the user's intent but doesn't try to re-order rows\nit didn't fetch.\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature,\n columnFilteringFeature } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let sort = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n let page = $state(0)\n const pageSize = 50\n\n let rows = $state<Person[]>([])\n let total = $state(0)\n let loading = $state(false)\n let controller: AbortController | null = null\n\n async function load() {\n controller?.abort()\n controller = new AbortController()\n loading = true\n try {\n const res = await fetch('/api/people?' + new URLSearchParams({\n sort: JSON.stringify(sort),\n filters: JSON.stringify(filters),\n page: String(page),\n size: String(pageSize),\n }), { signal: controller.signal })\n const body = await res.json()\n rows = body.rows\n total = body.total\n } catch (err) {\n if ((err as Error).name !== 'AbortError') throw err\n } finally {\n loading = false\n }\n }\n\n $effect(() => { sort; filters; page; load() })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n externalSort={true}\n externalFilter={true}\n showPagination={false}\n onSortingChange={(next) => { sort = next; page = 0 }}\n onFiltersChange={(next) => { filters = next.columns; page = 0 }}\n/>\n```\n\nThe [`09-server-side` demo](../../examples/src/demos/09-server-side.svelte)\nhas the full runnable version with debounce + abort + a 60 ms mock\nlatency. The [server-side guide](../help/server-side-data.md) covers\nsparse infinite scroll, velocity-aware chunk loading, and\nbackpressure.\n\n<div data-docs-demo=\"09-server-side\" data-height=\"520\"></div>\n\n## 2. Virtualization for large datasets\n\nFor more than ~2k rows, enable row virtualization. For very wide grids\n(50+ columns) also enable column virtualization. Both are opt-in so\nsmall grids don't pay the cost.\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n virtualization={true}\n columnVirtualization={true}\n overscan={8}\n columnOverscan={3}\n rowHeight={32}\n containerHeight={600}\n/>\n```\n\nThe wrapper's row + column virtualizers handle variable row heights via\nthe headless `createSvelteVirtualizer` / `createColumnVirtualizer`. See\n[demo 06](../../examples/src/demos/06-large-dataset.svelte) for 100k\nrows × 100 columns with smooth scroll.\n\n<div data-docs-demo=\"06-large-dataset\" data-height=\"520\"></div>\n\n## 3. Accessibility\n\nThe grid implements the WAI-ARIA 1.2 grid pattern out of the box. Every\nnode has the right role, the active cell carries focus, the keyboard\nmap matches what assistive tech expects.\n\nYou don't need to add anything for a baseline accessible grid. To go\nfurther:\n\n- Add `aria-live` announcements for changes outside the grid (filter\n applied, X rows selected) - see [demo 17](../../examples/src/demos/17-accessibility.svelte).\n- Toggle a high-contrast focus outline for users who need it -\n also in demo 17.\n- Read [Accessibility](../help/accessibility.md) for the WCAG 2.1 AA\n mapping, forced-colors-mode behaviour, and reduced-motion handling.\n\n<div data-docs-demo=\"17-accessibility\" data-height=\"500\"></div>\n\n## 4. SSR-friendly markup\n\nThe render component produces meaningful HTML before hydration. In a\nSvelteKit `+page.server.ts` load, the grid's markup hits the browser\nalready filled with data - first paint shows the table, hydration only\nattaches event listeners.\n\n```svelte {runnable}\n<!-- +page.svelte -->\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature } from '@svgrid/grid'\n let { data } = $props()\n</script>\n\n<SvGrid\n data={data.rows}\n columns={columns}\n features={tableFeatures({ rowSortingFeature })}\n/>\n```\n\nWhat is and is not in the server HTML:\n\n- **In it:** the header, and a viewport-sized window of rows with their real\n cell values. Enough for a crawler to index the content and for a no-JS client\n to read the table.\n- **Not in it:** rows below that first window. Virtualization survives SSR, so a\n 5,000-row grid does not serialise 5,000 rows into the page; the rest arrive\n when the client measures the viewport and takes over.\n\n`pnpm ssr:check` asserts this against a real `generate: 'server'` build and runs\nin CI. It exists because the grid silently stopped server-rendering rows for a\nwhile: both virtualizers learn their row and column count from an `$effect`, and\neffects never run during SSR, so the server emitted an empty `<tbody>` while\nthis page claimed otherwise.\n\n[Demo 19 - SSR](../../examples/src/demos/19-ssr.svelte) illustrates the *shape*\nof the pre-hydration markup by snapshotting the rendered grid into a\nscript-blocked iframe. Note it snapshots the client-rendered DOM, so it shows\nwhat the markup looks like without JS - it does not measure the server output.\n`pnpm ssr:check` is what actually verifies that.\n\n## 5. Content Security Policy\n\nSvGrid runs cleanly under a strict CSP - no `eval`, no `new Function`,\nno inline scripts, no inline event handlers. The recommended header:\n\n```\nContent-Security-Policy:\n default-src 'self';\n script-src 'self';\n style-src 'self' 'unsafe-inline';\n img-src 'self' data:;\n font-src 'self' data:;\n connect-src 'self';\n frame-ancestors 'none';\n base-uri 'self';\n form-action 'self';\n```\n\nNote: no `'unsafe-eval'` and no `'unsafe-inline'` on `script-src`.\n[Demo 16 - CSP-compliant](../../examples/src/demos/16-csp-compliant.svelte)\nruns a live runtime self-check + a violation listener inside a working\ngrid.\n\n## 6. TypeScript notes\n\n```ts\nimport type {\n ColumnDef,\n SvGridApi,\n SortingState,\n TableFeatures,\n} from '@svgrid/grid'\n\n// 1. Constrain ColumnDef to your row type so editors and accessors stay typed.\ntype Row = { id: string; firstName: string; age: number }\nconst columns: GridColumns<Row> = [\n { field: 'firstName', header: 'First' }, // OK\n { field: 'middleName', header: 'Mid' }, // ✗ \"middleName\" not on Row\n]\n\n// 2. The api type matches your features + row type.\nlet api = $state<SvGridApi<typeof features, Row> | null>(null)\n```\n\nThe features generic on `ColumnDef` is the type of the `features`\nobject - `tableFeatures({ rowSortingFeature })` produces a different\ntype than `tableFeatures({})`. Pass `typeof features` so column-level\ninference picks up which capabilities your grid has.\n\n## 7. What's next\n\n- [Why headless?](../why-headless.md) - the layered architecture and\n when to drop down to the headless core.\n- [Enterprise features](../enterprise/README.md) - export, import, AI, pivot.\n- [Help index](../help/index.md) - the topic-page catalogue.\n- [Recipes](../help/recipes.md) - 20+ copy-paste patterns.\n- [How SvGrid compares](../help/comparison.md) - what\n evaluators read.\n- [Missing features](../help/missing-features.md) - the honest gap list.\n"
3957
+ "markdown": "# 6. Going to production\n\n> Step 6 of 6 · [← Theme and density](./5-theme-and-density.md)\n\nYou have a working grid. This page is the checklist that turns it into\nsomething you'd ship.\n\n## 1. Server-side data\n\nFor datasets that don't fit in memory, drive the grid from the server.\nPair `externalSort` + `externalFilter` with the corresponding callbacks\nso the grid records the user's intent but doesn't try to re-order rows\nit didn't fetch.\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature,\n columnFilteringFeature } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let sort = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n let page = $state(0)\n const pageSize = 50\n\n let rows = $state<Person[]>([])\n let total = $state(0)\n let loading = $state(false)\n let controller: AbortController | null = null\n\n async function load() {\n controller?.abort()\n controller = new AbortController()\n loading = true\n try {\n const res = await fetch('/api/people?' + new URLSearchParams({\n sort: JSON.stringify(sort),\n filters: JSON.stringify(filters),\n page: String(page),\n size: String(pageSize),\n }), { signal: controller.signal })\n const body = await res.json()\n rows = body.rows\n total = body.total\n } catch (err) {\n if ((err as Error).name !== 'AbortError') throw err\n } finally {\n loading = false\n }\n }\n\n $effect(() => { sort; filters; page; load() })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n externalSort={true}\n externalFilter={true}\n showPagination={false}\n onSortingChange={(next) => { sort = next; page = 0 }}\n onFiltersChange={(next) => { filters = next.columns; page = 0 }}\n/>\n```\n\nThe [`09-server-side` demo](../../examples/src/demos/09-server-side.svelte)\nhas the full runnable version with debounce + abort + a 60 ms mock\nlatency. The [server-side guide](../help/server-side-data.md) covers\nsparse infinite scroll, velocity-aware chunk loading, and\nbackpressure.\n\n<div data-docs-demo=\"09-server-side\" data-height=\"520\"></div>\n\n## 2. Virtualization for large datasets\n\nFor more than ~2k rows, enable row virtualization. For very wide grids\n(50+ columns) also enable column virtualization. Both are opt-in so\nsmall grids don't pay the cost.\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n virtualization={true}\n columnVirtualization={true}\n overscan={8}\n columnOverscan={3}\n rowHeight={32}\n containerHeight={600}\n/>\n```\n\nThe wrapper's row + column virtualizers handle variable row heights via\nthe headless `createSvelteVirtualizer` / `createColumnVirtualizer`. See\n[demo 06](../../examples/src/demos/06-large-dataset.svelte) for 100k\nrows × 100 columns with smooth scroll.\n\n<div data-docs-demo=\"06-large-dataset\" data-height=\"520\"></div>\n\n## 3. Accessibility\n\nThe grid implements the WAI-ARIA 1.2 grid pattern out of the box. Every\nnode has the right role, the active cell carries focus, the keyboard\nmap matches what assistive tech expects.\n\nYou don't need to add anything for a baseline accessible grid. To go\nfurther:\n\n- Add `aria-live` announcements for changes outside the grid (filter\n applied, X rows selected) - see [demo 17](../../examples/src/demos/17-accessibility.svelte).\n- Toggle a high-contrast focus outline for users who need it -\n also in demo 17.\n- Read [Accessibility](../help/accessibility.md) for the WCAG 2.1 AA\n mapping, forced-colors-mode behaviour, and reduced-motion handling.\n\n<div data-docs-demo=\"17-accessibility\" data-height=\"500\"></div>\n\n## 4. SSR-friendly markup\n\nThe render component produces meaningful HTML before hydration. In a\nSvelteKit `+page.server.ts` load, the grid's markup hits the browser\nalready filled with data - first paint shows the table, hydration only\nattaches event listeners.\n\n```svelte {runnable}\n<!-- +page.svelte -->\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature } from '@svgrid/grid'\n let { data } = $props()\n</script>\n\n<SvGrid\n data={data.rows}\n columns={columns}\n features={tableFeatures({ rowSortingFeature })}\n/>\n```\n\nWhat is and is not in the server HTML:\n\n- **In it:** the header, and a viewport-sized window of rows with their real\n cell values. Enough for a crawler to index the content and for a no-JS client\n to read the table.\n- **Not in it:** rows below that first window. Virtualization survives SSR, so a\n 5,000-row grid does not serialise 5,000 rows into the page; the rest arrive\n when the client measures the viewport and takes over.\n\n`pnpm ssr:check` asserts this against a real `generate: 'server'` build and runs\nin CI. It exists because the grid silently stopped server-rendering rows for a\nwhile: both virtualizers learn their row and column count from an `$effect`, and\neffects never run during SSR, so the server emitted an empty `<tbody>` while\nthis page claimed otherwise.\n\n[Demo 19 - SSR](../../examples/src/demos/19-ssr.svelte) illustrates the *shape*\nof the pre-hydration markup by snapshotting the rendered grid into a\nscript-blocked iframe. Note it snapshots the client-rendered DOM, so it shows\nwhat the markup looks like without JS - it does not measure the server output.\n`pnpm ssr:check` is what actually verifies that.\n\n## 5. Content Security Policy\n\nSvGrid runs cleanly under a strict CSP - no `eval`, no `new Function`,\nno inline scripts, no inline event handlers. The recommended header:\n\n```\nContent-Security-Policy:\n default-src 'self';\n script-src 'self';\n style-src 'self' 'unsafe-inline';\n img-src 'self' data:;\n font-src 'self' data:;\n connect-src 'self';\n frame-ancestors 'none';\n base-uri 'self';\n form-action 'self';\n```\n\nNote: no `'unsafe-eval'` and no `'unsafe-inline'` on `script-src`.\n[Demo 16 - CSP-compliant](../../examples/src/demos/16-csp-compliant.svelte)\nruns a live runtime self-check + a violation listener inside a working\ngrid.\n\n## 6. TypeScript notes\n\n```ts\nimport type {\n ColumnDef,\n SvGridApi,\n SortingState,\n TableFeatures,\n} from '@svgrid/grid'\n\n// 1. Constrain ColumnDef to your row type so editors and accessors stay typed.\ntype Row = { id: string; firstName: string; age: number }\nconst columns: GridColumns<Row> = [\n { field: 'firstName', header: 'First' }, // OK\n { field: 'middleName', header: 'Mid' }, // ✗ \"middleName\" not on Row\n]\n\n// 2. The api type matches your features + row type.\nlet api = $state<SvGridApi<typeof features, Row> | null>(null)\n```\n\nThe features generic on `ColumnDef` is the type of the `features`\nobject - `tableFeatures({ rowSortingFeature })` produces a different\ntype than `tableFeatures({})`. Pass `typeof features` so column-level\ninference picks up which capabilities your grid has.\n\n## 7. What's next\n\n- [Why headless?](../why-headless.md) - the layered architecture and\n when to drop down to the headless core.\n- [Enterprise features](../enterprise/README.md) - export, import, AI, pivot.\n- [Help index](../help/index.md) - the topic-page catalogue.\n- [Recipes](../help/recipes.md) - 20+ copy-paste patterns.\n- [How SvGrid compares](../help/comparison.md) - what\n evaluators read.\n- [Missing features](../help/missing-features.md) - the honest gap list.\n\n<!-- tutorial:learn-10-production -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-10-production\" data-docs-tutorial=\"learn-10-production\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-10-production.mp4\" poster=\"/tutorials/learn-10-production.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 10: going to production, 108 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-10-production.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-10-production.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 10: going to production</strong> (108 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Your grid works. This last lesson is the review a colleague would give it: the four things that decide whether it survives contact with real data and real users.</p>\n<p>First, scale. Virtualization is on by default, so only the rows in view have DOM nodes. Fifty thousand rows by seventy-seven columns scroll in both directions, and the count of nodes stays roughly the same as it was with ten rows.</p>\n<p>What you do have to give it is a height. A grid inside a container with no height cannot know what is on screen, and that is the single most common reason a large table feels slow.</p>\n<p>Second, accessibility, which you mostly already have. It is a real table element carrying the ARIA grid pattern, so a screen reader announces rows and columns, and every cell is reachable from the keyboard.</p>\n<p>What is on you is the rest of the page: a label on the grid, contrast that passes, and not removing the focus ring. There is a high-contrast preset in the box if procurement asks.</p>\n<p>Third, the markup server-renders, so a viewport of real rows reaches the browser before hydration. And fourth, the habit that costs nothing: type the column array as GridColumns of your row. A bare GridColumns widens the row and stops checking field names, and a typo becomes a blank column nobody notices until a user does.</p>\n<p>That is the course. You can build a grid, shape its columns, sort and filter it, edit it, group it, theme it, feed it from a server and ship it. Everything past this point is a feature you turn on when you need it, and the demos on the site show each one running.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-10-production -->\n\n<!-- tutorial:learn-8-server-data -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-8-server-data\" data-docs-tutorial=\"learn-8-server-data\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-8-server-data.mp4\" poster=\"/tutorials/learn-8-server-data.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 8: data from a server, 106 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-8-server-data.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-8-server-data.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 8: data from a server</strong> (106 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Everything so far held every row in memory. That works to tens of thousands of rows. Past that, the sorting and filtering have to move to the server, and the grid has to ask for what it needs.</p>\n<p>The state the grid hands you is small: which column is sorted, which filters are set, which page you are on. Keep those in runes.</p>\n<p>Then one function that turns that state into a request. Notice the abort controller: when the user types another letter, the request already in flight is cancelled, so a slow earlier response cannot overwrite a newer one. That race is the most common bug in a server-backed table.</p>\n<p>An effect reruns it whenever sort, filters or page change, and the component passes the rows down with the total, so the pager knows how many pages there are without counting rows it never received.</p>\n<p>Here is that pattern running against a real endpoint. Sorting goes to the server and comes back sorted; typing in the search box is debounced, so one request goes out rather than one per keystroke.</p>\n<p>The loading state lives on the rows rather than as a spinner over the page, so the header and the controls stay usable while the next page arrives.</p>\n<p>Past a million rows there is a dedicated row model that lazily loads groups and blocks; it is linked from the page below. Lesson nine puts all of this in a SvelteKit app.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-8-server-data -->\n"
3958
+ },
3959
+ {
3960
+ "slug": "getting-started/course",
3961
+ "path": "docs/getting-started/course.md",
3962
+ "title": "Learn SvGrid: the video course",
3963
+ "section": "Getting started",
3964
+ "markdown": "# Learn SvGrid: the video course\n\nTen short lessons that take you from an empty project to a grid you would put\nin front of users. Each one is a few minutes, each ends where the next begins,\nand every line of code on screen is a file you can open: the lessons are\nrecorded against the runnable examples on these pages, so what you watch is\nwhat runs.\n\nWatch them in order the first time. After that they work as reference, since\neach lesson sits on the docs page for its subject and the page carries the\nsame code in text.\n\n## What you need\n\nA project with Svelte 5 and the package installed. [Install](./1-install.md)\ncovers both, and takes about a minute. Everything else is in the lessons.\n\nThe course assumes you know Svelte 5 runes well enough to read `$state`, and\nnothing about data grids.\n\n## The lessons\n\n<!-- course:learn -->\n\n| # | Lesson | What it covers | Length |\n| --- | --- | --- | --- |\n| 1 | [Install and your first grid](./2-first-grid.md#tutorial-learn-1-first-grid) | Install the package, write a twenty-line component, and get a styled, keyboard-navigable table. | 2 min 01 s |\n| 2 | [Your data, your columns](./3-data-and-columns.md#tutorial-learn-2-data-and-columns) | Put your own row type in, keep it reactive, then set headers, widths and currency, percent and date formats. | 1 min 28 s |\n| 3 | [Sorting, filtering and paging](./4-features.md#tutorial-learn-3-sort-filter-page) | Switch on sorting, filtering, grouping and pagination with boolean props, and see what each one does to the same table. | 2 min 23 s |\n| 4 | [Editing rows, and validating them](../help/editing/validation.md#tutorial-learn-4-editing) | Make cells editable, pick an editor per column, and use validate with rejectInvalid so bad input never reaches your data. | 1 min 31 s |\n| 5 | [Selection and bulk actions](../help/rows/selection-bar.md#tutorial-learn-5-selection) | Add row checkboxes, give the grid a stable row id, and float a bulk-action bar over the rows the user picked. | 1 min 26 s |\n| 6 | [Grouping and totals](../help/grouping-aggregation.md#tutorial-learn-6-grouping) | Group rows by a column, average and sum per group with the aggregate option, and add a summary row for the whole table. | 1 min 34 s |\n| 7 | [Themes, dark mode and density](./5-theme-and-density.md#tutorial-learn-7-theme) | Set the CSS custom properties yourself, flip dark mode with one attribute, and change row density with a prop. | 1 min 53 s |\n| 8 | [Data from a server](./6-going-to-production.md#tutorial-learn-8-server-data) | Move sorting, filtering and paging to the backend, send one request per change, and cancel the one that is now stale. | 1 min 46 s |\n| 9 | [SvelteKit end to end](./sveltekit.md#tutorial-learn-9-sveltekit) | Query in a load function, hand the rows to the page, and get real server-rendered HTML instead of an empty shell. | 1 min 21 s |\n| 10 | [Going to production](./6-going-to-production.md#tutorial-learn-10-production) | Virtualization, the accessibility you already have, server-rendered markup, and the TypeScript habit that prevents blank columns. | 1 min 48 s |\n\nTen lessons, 17 min 10 s in all.\n\n<!-- /course:learn -->\n\n## How the lessons are made\n\nThe code you see typed in the editor is not a screenshot or a re-typed copy.\nEach lesson reads a runnable block straight out of these docs, types that\nfile's own source, and mounts that same file in the browser pane beside it. So\nthe code shown and the result shown cannot drift apart, and when a doc changes\nthe lesson is re-recorded from it rather than edited to match.\n\nEvery claim the narration makes is also checked while recording. When a lesson\nsays a button adds a row, the recorder counts the rows before and after and\nfails the take if the count did not move. That check exists because a take\nonce shipped where it had not.\n\n## See also\n\n- [Install](./1-install.md) - the one-minute setup the course assumes.\n- [Starters](./starters.md) - a scaffolded project if you would rather not\n wire one up by hand.\n- [SvelteKit](./sveltekit.md) - the same grid in a SvelteKit app, end to end.\n- [Going to production](./6-going-to-production.md) - the checks before you\n ship, which is where lesson ten ends.\n"
3958
3965
  },
3959
3966
  {
3960
3967
  "slug": "getting-started/starters",
@@ -3968,7 +3975,7 @@ export const docs = [
3968
3975
  "path": "docs/getting-started/sveltekit.md",
3969
3976
  "title": "SvGrid with SvelteKit",
3970
3977
  "section": "Getting started",
3971
- "markdown": "# SvGrid with SvelteKit\n\nA grid in a SvelteKit app is not the same job as a grid in a Vite SPA. The data\nusually comes from a `+page.server.ts` load, the page has to server-render for\ncrawlers, sorting should survive a refresh and a shared link, and edits go back\nthrough a form action. This page walks all four.\n\nEverything here was run against a real SvelteKit app before it was written -\n`sv create`, `sv add`, `svelte-check`, `vite build`, and the server HTML checked\nover HTTP.\n\n## Run it before you read it\n\nEverything this page builds is also a template, so you can have it on your\nmachine in three commands:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit\ncd people && npm install && npm run dev\n```\n\nThat is the app from [the end-to-end section](#the-whole-thing-end-to-end)\nbelow, plus a theme picker in the header - all 20 presets, light and dark,\nswitched live while the grid is running. Pick the starting theme up front if you\nprefer:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit --theme dracula --dark\n```\n\n`--theme` writes the preset into `src/app.css`, so the first paint and the\nserver-rendered HTML are already themed rather than flashing a frame of the\ndefault. Leave `--dark`/`--light` off and the app follows the visitor's OS\nsetting instead. `npm create @svgrid@latest -- --help` lists every preset id.\n\nThe rest of this page builds the same thing by hand, which is the better way to\nunderstand it.\n\n## 1. Scaffold\n\nSvGrid ships an add-on for the [Svelte CLI](https://svelte.dev/docs/cli), so a\nnew app is two commands:\n\n```bash\nnpx sv create myapp # pick SvelteKit, TypeScript\ncd myapp\nnpx sv add @svgrid\n```\n\n`sv add` resolves `@svgrid` to `@svgrid/sv`, adds `@svgrid/grid` to your\ndependencies, and - if you say yes to the demo - writes a working grid to\n`src/routes/svgrid-demo/+page.svelte`. To skip the prompts:\n\n```bash\nnpx sv add \"@svgrid=demo:yes+enterprise:no\" --no-download-check\n```\n\nTwo things worth knowing. `sv add` must run **inside** a project - in an empty\ndirectory it fails with *\"Invalid workspace\"*, which is the CLI's own guard, not\nthe add-on. And community add-ons are still marked experimental upstream, so if\n`sv` changes under you, `npm install @svgrid/grid` does the same job.\n\nAdding to an existing app is just the install:\n\n```bash\nnpm install @svgrid/grid\n```\n\n## 2. Load data on the server\n\nPut the query in `+page.server.ts` and hand the rows to the page. Nothing about\nSvGrid is special here - it takes a plain array.\n\n```ts\n// src/routes/people/+page.server.ts\nimport type { PageServerLoad } from './$types'\nimport { listPeople } from '$lib/db'\n\nexport const load: PageServerLoad = () => {\n return { rows: listPeople() }\n}\n```\n\n```svelte\n<!-- src/routes/people/+page.svelte -->\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/db'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name' },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n</script>\n\n<SvGrid data={data.rows} {columns} sortable containerHeight={320} />\n```\n\nType the column array against your row type. `GridColumns<Person>` checks every\n`field` against real keys; a bare `GridColumns` widens the row to\n`Record<string, unknown>` and stops checking them.\n\n## 3. What actually server-renders\n\nThe server HTML contains the header and a **viewport-sized window of rows** with\ntheir real cell values. That is what a crawler indexes and what a reader with JS\ndisabled sees.\n\nIt does *not* contain every row. Virtualization survives SSR, so a 5,000-row\ngrid does not serialise 5,000 rows into the page - the rest arrive when the\nclient measures the viewport and takes over. Hydration is clean because the\nserver and the first client render produce the same markup.\n\nYou can check it yourself on any page:\n\n```bash\nnpm run build && npm run preview\ncurl -s http://localhost:4173/people | grep -c 'data-svgrid-row'\n```\n\nA non-zero count means rows are in the HTML.\n\n> This genuinely did not work before `@svgrid/grid@2.6.8`. Both virtualizers\n> learned their row count from an `$effect`, and effects never run during SSR,\n> so the server emitted an empty `<tbody>`. If you are on an older version and\n> SEO matters, upgrade.\n\n## 4. Sorting that survives a refresh\n\nThe idiomatic SvelteKit move is to keep sort state in the URL. The link is then\nshareable and bookmarkable, the back button works, and the sorted page is\nserver-rendered - so it works with JS disabled too.\n\nSort on the server from the query string:\n\n```ts\n// src/routes/people/+page.server.ts\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n```\n\nThen tell the grid the server owns the ordering, and push header clicks into the\nURL:\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n</script>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n/>\n```\n\n`externalSort` is the important prop: the grid keeps rendering sort indicators\nand cycling on header click, but stops reordering rows itself - because the rows\narriving from `load` are already in the right order. Without it the grid sorts\nthe page a second time, which is wasted work and goes wrong the moment the\nserver is paginating.\n\n`keepFocus` and `noScroll` stop the navigation stealing focus from the header\nyou just clicked or jumping the page to the top.\n\n## 5. Saving an edit through a form action\n\nTurn on editing, then push each committed change at an action:\n\n```ts\n// +page.server.ts\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const form = await request.formData()\n renamePerson(Number(form.get('id')), String(form.get('name')))\n return { success: true }\n },\n}\n```\n\n```svelte\n<script lang=\"ts\">\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<SvGrid data={data.rows} {columns} editable {onCellValueChange} />\n```\n\nThe grid updates its own copy of the row immediately, so the cell shows the new\nvalue while the request is in flight. Handle a rejected save by reloading\n(`invalidateAll()`) or by writing the old value back.\n\nSvelteKit's CSRF protection rejects a cross-origin POST to an action. Browsers\nsend the `Origin` header themselves so this is invisible in an app - but if you\nscript the endpoint from Node or curl, set `Origin` or you will get\n`403 Cross-site POST form submissions are forbidden`.\n\n## The whole thing, end to end\n\nFour commands and three files. Every line below was run from a clean scaffold\nbefore it was published here: `svelte-check` reports 0 errors, the page\nserver-renders its rows, clicking a header rewrites the URL, and an edit\nsurvives a reload.\n\nIf you would rather run it than type it,\n`npm create @svgrid@latest people -- --template sveltekit` gives you this app\nwith a theme picker already wired in. The files below are what it contains.\n\n```bash\nnpx sv create people --template minimal --types ts\ncd people\nnpx sv add \"@svgrid=demo:no+enterprise:no\" --no-download-check\nnpm install\n```\n\n**`src/lib/people.ts`** - stands in for your database.\n\n```ts\nexport type Person = { id: number; name: string; role: string; year: number }\n\nconst people: Person[] = [\n { id: 1, name: 'Ada Lovelace', role: 'Mathematician', year: 1843 },\n { id: 2, name: 'Grace Hopper', role: 'Rear Admiral', year: 1952 },\n { id: 3, name: 'Karen Sparck Jones', role: 'Computer Scientist', year: 1972 },\n { id: 4, name: 'Barbara Liskov', role: 'Computer Scientist', year: 1968 },\n { id: 5, name: 'Margaret Hamilton', role: 'Software Engineer', year: 1969 },\n]\n\nexport function listPeople(sortBy: keyof Person = 'name', desc = false): Person[] {\n const rows = [...people]\n rows.sort((a, b) => (a[sortBy] > b[sortBy] ? 1 : a[sortBy] < b[sortBy] ? -1 : 0))\n return desc ? rows.reverse() : rows\n}\n\nexport function renamePerson(id: number, name: string): void {\n const row = people.find((p) => p.id === id)\n if (row) row.name = name\n}\n```\n\n**`src/routes/people/+page.server.ts`** - sorts from the query string, and takes\nthe edit.\n\n```ts\nimport type { Actions, PageServerLoad } from './$types'\nimport { listPeople, renamePerson, type Person } from '$lib/people'\n\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const data = await request.formData()\n renamePerson(Number(data.get('id')), String(data.get('name')))\n return { success: true }\n },\n}\n```\n\n**`src/routes/people/+page.svelte`**\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/people'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', editable: true },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n\n // Header click -> URL -> server sorts -> load returns ordered rows.\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n\n // Committed edit -> form action -> database.\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<h1>People</h1>\n<p>Click a header to sort - the order lives in the URL. Double-click a name to edit it.</p>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n editable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n {onCellValueChange}\n containerHeight={320}\n/>\n```\n\nThen:\n\n```bash\nnpm run dev # http://localhost:5173/people\n```\n\nThree things to try, in this order:\n\n1. **Click the `Year` header.** The URL becomes `?sort=year&dir=asc`. Copy that\n link into a new tab - it opens already sorted, because the server did it.\n2. **Double-click a name, change it, press Enter, then reload.** The edit went\n through the form action and survived.\n3. **View source, or `curl localhost:5173/people`.** The rows are in the HTML,\n not just injected by JS. That is what a crawler sees.\n\n## 6. Going further\n\n- **Server-side paging and filtering** for large tables: `externalFilter` and\n `externalPagination` are the equivalents of `externalSort` above. See\n [server paging](../help/server/server-paging.md),\n [server filtering](../help/server/server-filtering.md) and the\n [server row model](../help/server/server-row-model.md), which wraps the whole\n request shape rather than wiring each prop by hand.\n- **Owning the markup.** If you need a table only you could write, the headless\n engine is importable on its own from `@svgrid/grid/core` and runs anywhere,\n including inside a load function. See [Why headless?](../why-headless.md).\n- **Themes and dark mode.** Set the theme attribute before first paint from an\n inline script in `app.html`, or the page renders in the wrong palette for a\n frame. The `sveltekit` template above ships that script plus a runtime picker\n built on `resolveThemeTokens` from `@svgrid/grid/themes`, which is the short\n way to see the pattern. [Theme and density](./5-theme-and-density.md) covers\n it properly.\n- **Gating it behind a login.** The `sveltekit` template ships an auth scaffold:\n a cookie session, PBKDF2 password hashing over Web Crypto (so it still runs on\n an edge adapter), and one `PROTECTED` list in `hooks.server.ts` that gates routes\n before any load runs. The role check sits in the form action, not only in the\n UI - hiding an edit button stops nobody from posting the action by hand.\n- **Deployment.** SvGrid is a normal client dependency with no build step or\n server runtime of its own, so any SvelteKit adapter works unchanged.\n\n## See also\n\n- [Going to production](./6-going-to-production.md) - CSP, SSR, accessibility\n- [First grid](./2-first-grid.md) - the framework-agnostic walkthrough\n- [Why headless?](../why-headless.md) - when to skip the render component\n"
3978
+ "markdown": "# SvGrid with SvelteKit\n\nA grid in a SvelteKit app is not the same job as a grid in a Vite SPA. The data\nusually comes from a `+page.server.ts` load, the page has to server-render for\ncrawlers, sorting should survive a refresh and a shared link, and edits go back\nthrough a form action. This page walks all four.\n\nEverything here was run against a real SvelteKit app before it was written -\n`sv create`, `sv add`, `svelte-check`, `vite build`, and the server HTML checked\nover HTTP.\n\n## Run it before you read it\n\nEverything this page builds is also a template, so you can have it on your\nmachine in three commands:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit\ncd people && npm install && npm run dev\n```\n\nThat is the app from [the end-to-end section](#the-whole-thing-end-to-end)\nbelow, plus a theme picker in the header - all 20 presets, light and dark,\nswitched live while the grid is running. Pick the starting theme up front if you\nprefer:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit --theme dracula --dark\n```\n\n`--theme` writes the preset into `src/app.css`, so the first paint and the\nserver-rendered HTML are already themed rather than flashing a frame of the\ndefault. Leave `--dark`/`--light` off and the app follows the visitor's OS\nsetting instead. `npm create @svgrid@latest -- --help` lists every preset id.\n\nThe rest of this page builds the same thing by hand, which is the better way to\nunderstand it.\n\n## 1. Scaffold\n\nSvGrid ships an add-on for the [Svelte CLI](https://svelte.dev/docs/cli), so a\nnew app is two commands:\n\n```bash\nnpx sv create myapp # pick SvelteKit, TypeScript\ncd myapp\nnpx sv add @svgrid\n```\n\n`sv add` resolves `@svgrid` to `@svgrid/sv`, adds `@svgrid/grid` to your\ndependencies, and - if you say yes to the demo - writes a working grid to\n`src/routes/svgrid-demo/+page.svelte`. To skip the prompts:\n\n```bash\nnpx sv add \"@svgrid=demo:yes+enterprise:no\" --no-download-check\n```\n\nTwo things worth knowing. `sv add` must run **inside** a project - in an empty\ndirectory it fails with *\"Invalid workspace\"*, which is the CLI's own guard, not\nthe add-on. And community add-ons are still marked experimental upstream, so if\n`sv` changes under you, `npm install @svgrid/grid` does the same job.\n\nAdding to an existing app is just the install:\n\n```bash\nnpm install @svgrid/grid\n```\n\n## 2. Load data on the server\n\nPut the query in `+page.server.ts` and hand the rows to the page. Nothing about\nSvGrid is special here - it takes a plain array.\n\n```ts\n// src/routes/people/+page.server.ts\nimport type { PageServerLoad } from './$types'\nimport { listPeople } from '$lib/db'\n\nexport const load: PageServerLoad = () => {\n return { rows: listPeople() }\n}\n```\n\n```svelte\n<!-- src/routes/people/+page.svelte -->\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/db'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name' },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n</script>\n\n<SvGrid data={data.rows} {columns} sortable containerHeight={320} />\n```\n\nType the column array against your row type. `GridColumns<Person>` checks every\n`field` against real keys; a bare `GridColumns` widens the row to\n`Record<string, unknown>` and stops checking them.\n\n## 3. What actually server-renders\n\nThe server HTML contains the header and a **viewport-sized window of rows** with\ntheir real cell values. That is what a crawler indexes and what a reader with JS\ndisabled sees.\n\nIt does *not* contain every row. Virtualization survives SSR, so a 5,000-row\ngrid does not serialise 5,000 rows into the page - the rest arrive when the\nclient measures the viewport and takes over. Hydration is clean because the\nserver and the first client render produce the same markup.\n\nYou can check it yourself on any page:\n\n```bash\nnpm run build && npm run preview\ncurl -s http://localhost:4173/people | grep -c 'data-svgrid-row'\n```\n\nA non-zero count means rows are in the HTML.\n\n> This genuinely did not work before `@svgrid/grid@2.6.8`. Both virtualizers\n> learned their row count from an `$effect`, and effects never run during SSR,\n> so the server emitted an empty `<tbody>`. If you are on an older version and\n> SEO matters, upgrade.\n\n## 4. Sorting that survives a refresh\n\nThe idiomatic SvelteKit move is to keep sort state in the URL. The link is then\nshareable and bookmarkable, the back button works, and the sorted page is\nserver-rendered - so it works with JS disabled too.\n\nSort on the server from the query string:\n\n```ts\n// src/routes/people/+page.server.ts\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n```\n\nThen tell the grid the server owns the ordering, and push header clicks into the\nURL:\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n</script>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n/>\n```\n\n`externalSort` is the important prop: the grid keeps rendering sort indicators\nand cycling on header click, but stops reordering rows itself - because the rows\narriving from `load` are already in the right order. Without it the grid sorts\nthe page a second time, which is wasted work and goes wrong the moment the\nserver is paginating.\n\n`keepFocus` and `noScroll` stop the navigation stealing focus from the header\nyou just clicked or jumping the page to the top.\n\n## 5. Saving an edit through a form action\n\nTurn on editing, then push each committed change at an action:\n\n```ts\n// +page.server.ts\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const form = await request.formData()\n renamePerson(Number(form.get('id')), String(form.get('name')))\n return { success: true }\n },\n}\n```\n\n```svelte\n<script lang=\"ts\">\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<SvGrid data={data.rows} {columns} editable {onCellValueChange} />\n```\n\nThe grid updates its own copy of the row immediately, so the cell shows the new\nvalue while the request is in flight. Handle a rejected save by reloading\n(`invalidateAll()`) or by writing the old value back.\n\nSvelteKit's CSRF protection rejects a cross-origin POST to an action. Browsers\nsend the `Origin` header themselves so this is invisible in an app - but if you\nscript the endpoint from Node or curl, set `Origin` or you will get\n`403 Cross-site POST form submissions are forbidden`.\n\n## The whole thing, end to end\n\nFour commands and three files. Every line below was run from a clean scaffold\nbefore it was published here: `svelte-check` reports 0 errors, the page\nserver-renders its rows, clicking a header rewrites the URL, and an edit\nsurvives a reload.\n\nIf you would rather run it than type it,\n`npm create @svgrid@latest people -- --template sveltekit` gives you this app\nwith a theme picker already wired in. The files below are what it contains.\n\n```bash\nnpx sv create people --template minimal --types ts\ncd people\nnpx sv add \"@svgrid=demo:no+enterprise:no\" --no-download-check\nnpm install\n```\n\n**`src/lib/people.ts`** - stands in for your database.\n\n```ts\nexport type Person = { id: number; name: string; role: string; year: number }\n\nconst people: Person[] = [\n { id: 1, name: 'Ada Lovelace', role: 'Mathematician', year: 1843 },\n { id: 2, name: 'Grace Hopper', role: 'Rear Admiral', year: 1952 },\n { id: 3, name: 'Karen Sparck Jones', role: 'Computer Scientist', year: 1972 },\n { id: 4, name: 'Barbara Liskov', role: 'Computer Scientist', year: 1968 },\n { id: 5, name: 'Margaret Hamilton', role: 'Software Engineer', year: 1969 },\n]\n\nexport function listPeople(sortBy: keyof Person = 'name', desc = false): Person[] {\n const rows = [...people]\n rows.sort((a, b) => (a[sortBy] > b[sortBy] ? 1 : a[sortBy] < b[sortBy] ? -1 : 0))\n return desc ? rows.reverse() : rows\n}\n\nexport function renamePerson(id: number, name: string): void {\n const row = people.find((p) => p.id === id)\n if (row) row.name = name\n}\n```\n\n**`src/routes/people/+page.server.ts`** - sorts from the query string, and takes\nthe edit.\n\n```ts\nimport type { Actions, PageServerLoad } from './$types'\nimport { listPeople, renamePerson, type Person } from '$lib/people'\n\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const data = await request.formData()\n renamePerson(Number(data.get('id')), String(data.get('name')))\n return { success: true }\n },\n}\n```\n\n**`src/routes/people/+page.svelte`**\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/people'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', editable: true },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n\n // Header click -> URL -> server sorts -> load returns ordered rows.\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n\n // Committed edit -> form action -> database.\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<h1>People</h1>\n<p>Click a header to sort - the order lives in the URL. Double-click a name to edit it.</p>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n editable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n {onCellValueChange}\n containerHeight={320}\n/>\n```\n\nThen:\n\n```bash\nnpm run dev # http://localhost:5173/people\n```\n\nThree things to try, in this order:\n\n1. **Click the `Year` header.** The URL becomes `?sort=year&dir=asc`. Copy that\n link into a new tab - it opens already sorted, because the server did it.\n2. **Double-click a name, change it, press Enter, then reload.** The edit went\n through the form action and survived.\n3. **View source, or `curl localhost:5173/people`.** The rows are in the HTML,\n not just injected by JS. That is what a crawler sees.\n\n## 6. Going further\n\n- **Server-side paging and filtering** for large tables: `externalFilter` and\n `externalPagination` are the equivalents of `externalSort` above. See\n [server paging](../help/server/server-paging.md),\n [server filtering](../help/server/server-filtering.md) and the\n [server row model](../help/server/server-row-model.md), which wraps the whole\n request shape rather than wiring each prop by hand.\n- **Owning the markup.** If you need a table only you could write, the headless\n engine is importable on its own from `@svgrid/grid/core` and runs anywhere,\n including inside a load function. See [Why headless?](../why-headless.md).\n- **Themes and dark mode.** Set the theme attribute before first paint from an\n inline script in `app.html`, or the page renders in the wrong palette for a\n frame. The `sveltekit` template above ships that script plus a runtime picker\n built on `resolveThemeTokens` from `@svgrid/grid/themes`, which is the short\n way to see the pattern. [Theme and density](./5-theme-and-density.md) covers\n it properly.\n- **Gating it behind a login.** The `sveltekit` template ships an auth scaffold:\n a cookie session, PBKDF2 password hashing over Web Crypto (so it still runs on\n an edge adapter), and one `PROTECTED` list in `hooks.server.ts` that gates routes\n before any load runs. The role check sits in the form action, not only in the\n UI - hiding an edit button stops nobody from posting the action by hand.\n- **Deployment.** SvGrid is a normal client dependency with no build step or\n server runtime of its own, so any SvelteKit adapter works unchanged.\n\n<!-- tutorial:learn-9-sveltekit -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-9-sveltekit\" data-docs-tutorial=\"learn-9-sveltekit\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-9-sveltekit.mp4\" poster=\"/tutorials/learn-9-sveltekit.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 9: SvelteKit end to end, 81 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-9-sveltekit.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-9-sveltekit.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 9: SvelteKit end to end</strong> (81 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>SvelteKit gives you a place to put the query that is already on the server. The grid does not need to know about any of it: it takes an array.</p>\n<p>There is a SvelteKit template, so the whole app is two commands. If you have a project already, installing the package is enough.</p>\n<p>The load function runs on the server. Your database credentials are already there, so the query belongs there too, and the browser never sees it.</p>\n<p>The page takes what that returned and hands it straight to the grid. Type the columns against your row type, as in lesson two, and the compiler checks the field names for you.</p>\n<p>Here is a grid over a real endpoint, which is what your page becomes once the load function points at a database instead of a demo API.</p>\n<p>The part worth knowing: the server HTML contains a viewport of real rows, not an empty container waiting for hydration. A crawler sees data, and a reader sees the table before the JavaScript arrives.</p>\n<p>URL-driven sorting and form actions for edits are on the page below. One lesson to go.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-9-sveltekit -->\n\n## See also\n\n- [Going to production](./6-going-to-production.md) - CSP, SSR, accessibility\n- [First grid](./2-first-grid.md) - the framework-agnostic walkthrough\n- [Why headless?](../why-headless.md) - when to skip the render component\n"
3972
3979
  },
3973
3980
  {
3974
3981
  "slug": "help/accessibility",
@@ -4066,7 +4073,7 @@ export const docs = [
4066
4073
  "path": "docs/help/cells/cell-data-types.md",
4067
4074
  "title": "Cell data types",
4068
4075
  "section": "Cells",
4069
- "markdown": "# Cell data types\n\nThe `editorType` field on a column tags the column with a type. This drives\nthree different behaviours:\n<div data-docs-demo=\"01-quick-start\" data-height=\"540\"></div>\n\n| Effect | Driven by `editorType` |\n| ------ | --------------------- |\n| Which inline editor opens on `F2` / double-click | yes - `text` / `number` / `date` / `datetime` / `checkbox` |\n| Which sort comparator is used | yes - `number` and `date`/`datetime` pick non-default `sortFns` |\n| Which filter operators the column menu offers | yes - text / number / date / checkbox sets differ |\n\n## Setting it\n\n```ts\nconst columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n { field: 'startTime', header: 'Start', editorType: 'datetime' },\n { field: 'active', header: 'Active', editorType: 'checkbox' },\n]\n```\n\nEven if you do not enable inline editing, set `editorType` so sort and\nfilter behave correctly for the column's data type. A `number` column\nwithout `editorType` sorts as lexical strings.\n\n## `cellDataType` shorthand\n\n`cellDataType` is a higher-level alias that resolves to the right `editorType`,\nalignment, date `format`, and filter operators in one word - so you don't hand-set\neach:\n\n```ts\nconst columns = [\n { field: 'name', cellDataType: 'text' },\n { field: 'age', cellDataType: 'number' }, // number editor, right-aligned, numeric filters\n { field: 'active', cellDataType: 'boolean' }, // checkbox editor, centered\n { field: 'joined', cellDataType: 'date' }, // Date values, `{ type: 'date' }` format\n { field: 'due', cellDataType: 'dateString' }, // ISO date STRINGS ('2026-06-27')\n]\n```\n\n| `cellDataType` | resolves to `editorType` | + format |\n| --- | --- | --- |\n| `text` | `text` | - |\n| `number` | `number` | - |\n| `boolean` | `checkbox` | - |\n| `date` | `date` | `{ type: 'date' }` |\n| `dateString` | `date` | - |\n\nAnything you set explicitly (`editorType`, `align`, `format`) always wins -\n`cellDataType` only fills the gaps.\n\n## Inferring types from the data\n\nSet `inferColumnTypes` on the grid and any column that declares **neither**\n`editorType` **nor** `cellDataType` has its type inferred from the first data\nrow (number / boolean / `Date` / ISO date-string / text):\n\n```svelte\n<SvGrid {data} {columns} inferColumnTypes />\n```\n\n## Built-in types\n\n| `editorType` | accepted value space |\n| ------------ | -------------------- |\n| `'text'` | strings |\n| `'number'` | numbers (or numeric strings) |\n| `'date'` | ISO date strings (`YYYY-MM-DD`) or `Date` |\n| `'datetime'` | ISO datetime strings or `Date` |\n| `'checkbox'` | booleans |\n\n## Custom types\n\nThere is no plug-in \"register a new cell data type\" API. To use a custom\ntype:\n\n- Render with a custom `cell` (see [Cell components](./cell-components.md))\n- Sort with a custom value through `fieldFn` that normalises to a\n comparable primitive\n- Filter with a custom operator UI in your own header component\n\n## Editor value parsing\n\nThe editor receives a string from the DOM and converts to the canonical\nvalue before commit. The implementation lives in\n[`parseEditorValue`](../../../packages/grid/src/editors/cell-editors.ts).\n\n```ts\nimport { parseEditorValue } from '@svgrid/grid'\n\nparseEditorValue('number', '42') // 42\nparseEditorValue('number', 'abc') // NaN - caller should reject\nparseEditorValue('checkbox', 'true') // true\nparseEditorValue('date', '2026-05-27') // '2026-05-27'\n```\n\n## Telling the grid what a string holds\n\nA date that arrives as a string sorts alphabetically unless you say otherwise.\n`cellDataType: 'dateString'` is that declaration, and it changes sorting and the\nfilter operators together.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n joined: string\n active: boolean\n trend: number[]\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, joined: '2021-03-01', active: true, trend: [4, 6, 5, 9, 12, 11, 15] },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15', active: true, trend: [9, 8, 11, 10, 14, 16, 15] },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20', active: false, trend: [12, 10, 9, 7, 6, 6, 4] },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, joined: '2022-09-05', active: true, trend: [3, 5, 4, 8, 7, 11, 13] },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n // Without cellDataType this sorts as text, which puts 2019 after 2021-03.\n { field: 'joined', header: 'Joined', width: 150, cellDataType: 'dateString',\n format: { type: 'date', options: { dateStyle: 'medium' } } },\n ]\n</script>\n\n<SvGrid data={people} {columns} sortable filterable filterMode=\"menu\" />\n```\n\n\n## Booleans\n\n`cellDataType: 'boolean'` gives the column the true/false filter rather than a\ntext box, and pairs with the checkbox editor so reading and writing agree.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n joined: string\n active: boolean\n trend: number[]\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, joined: '2021-03-01', active: true, trend: [4, 6, 5, 9, 12, 11, 15] },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15', active: true, trend: [9, 8, 11, 10, 14, 16, 15] },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20', active: false, trend: [12, 10, 9, 7, 6, 6, 4] },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, joined: '2022-09-05', active: true, trend: [3, 5, 4, 8, 7, 11, 13] },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'active', header: 'Active', width: 120,\n cellDataType: 'boolean', editorType: 'checkbox' },\n ]\n</script>\n\n<SvGrid data={people} {columns} filterable filterMode=\"menu\" editable />\n```\n\n## See also\n\n- [Provided cell editors](../editing/provided-editors.md)\n- [Filter conditions](../filtering/filter-conditions.md)\n"
4076
+ "markdown": "# Cell data types\n\nThe `editorType` field on a column tags the column with a type. This drives\nthree different behaviours:\n<div data-docs-demo=\"01-quick-start\" data-height=\"540\"></div>\n\n| Effect | Driven by `editorType` |\n| ------ | --------------------- |\n| Which inline editor opens on `F2` / double-click | yes - `text` / `number` / `date` / `datetime` / `checkbox` |\n| Which sort comparator is used | yes - `number` and `date`/`datetime` pick non-default `sortFns` |\n| Which filter operators the column menu offers | yes - text / number / date / checkbox sets differ |\n\n## Setting it\n\n```ts\nconst columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n { field: 'startTime', header: 'Start', editorType: 'datetime' },\n { field: 'active', header: 'Active', editorType: 'checkbox' },\n]\n```\n\nEven if you do not enable inline editing, set `editorType` so sort and\nfilter behave correctly for the column's data type. Without it, sorting\nfalls back to the `auto` comparator: a column whose values are all numbers\nstill sorts numerically, but numbers stored as strings (`'10'`, `'9'`) sort\nas text, and a column mixing numbers and text puts the numbers first.\n\nEvery built-in comparator sorts blanks (`null`, `undefined`, `''`) first in\nascending order and last in descending. The `number` and `date` comparators\ntreat values they cannot parse (`'n/a'`, an invalid date) the same way.\n\n## `cellDataType` shorthand\n\n`cellDataType` is a higher-level alias that resolves to the right `editorType`,\nalignment, date `format`, and filter operators in one word - so you don't hand-set\neach:\n\n```ts\nconst columns = [\n { field: 'name', cellDataType: 'text' },\n { field: 'age', cellDataType: 'number' }, // number editor, right-aligned, numeric filters\n { field: 'active', cellDataType: 'boolean' }, // checkbox editor, centered\n { field: 'joined', cellDataType: 'date' }, // Date values, `{ type: 'date' }` format\n { field: 'due', cellDataType: 'dateString' }, // ISO date STRINGS ('2026-06-27')\n]\n```\n\n| `cellDataType` | resolves to `editorType` | + format |\n| --- | --- | --- |\n| `text` | `text` | - |\n| `number` | `number` | - |\n| `boolean` | `checkbox` | - |\n| `date` | `date` | `{ type: 'date' }` |\n| `dateString` | `date` | - |\n\nAnything you set explicitly (`editorType`, `align`, `format`) always wins -\n`cellDataType` only fills the gaps.\n\n## Inferring types from the data\n\nSet `inferColumnTypes` on the grid and any column that declares **neither**\n`editorType` **nor** `cellDataType` has its type inferred from the first data\nrow (number / boolean / `Date` / ISO date-string / text):\n\n```svelte\n<SvGrid {data} {columns} inferColumnTypes />\n```\n\n## Built-in types\n\n| `editorType` | accepted value space |\n| ------------ | -------------------- |\n| `'text'` | strings |\n| `'number'` | numbers (or numeric strings) |\n| `'date'` | ISO date strings (`YYYY-MM-DD`) or `Date` |\n| `'datetime'` | ISO datetime strings or `Date` |\n| `'checkbox'` | booleans |\n\n## Custom types\n\nThere is no plug-in \"register a new cell data type\" API. To use a custom\ntype:\n\n- Render with a custom `cell` (see [Cell components](./cell-components.md))\n- Sort with a custom value through `fieldFn` that normalises to a\n comparable primitive\n- Filter with a custom operator UI in your own header component\n\n## Editor value parsing\n\nThe editor receives a string from the DOM and converts to the canonical\nvalue before commit. The implementation lives in\n[`parseEditorValue`](../../../packages/grid/src/editors/cell-editors.ts).\n\n```ts\nimport { parseEditorValue } from '@svgrid/grid'\n\nparseEditorValue('number', '42') // 42\nparseEditorValue('number', 'abc') // NaN - caller should reject\nparseEditorValue('checkbox', 'true') // true\nparseEditorValue('date', '2026-05-27') // '2026-05-27'\n```\n\n## Telling the grid what a string holds\n\nA date that arrives as a string sorts alphabetically unless you say otherwise.\n`cellDataType: 'dateString'` is that declaration, and it changes sorting and the\nfilter operators together.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n joined: string\n active: boolean\n trend: number[]\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, joined: '2021-03-01', active: true, trend: [4, 6, 5, 9, 12, 11, 15] },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15', active: true, trend: [9, 8, 11, 10, 14, 16, 15] },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20', active: false, trend: [12, 10, 9, 7, 6, 6, 4] },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, joined: '2022-09-05', active: true, trend: [3, 5, 4, 8, 7, 11, 13] },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n // Without cellDataType this sorts as text, which puts 2019 after 2021-03.\n { field: 'joined', header: 'Joined', width: 150, cellDataType: 'dateString',\n format: { type: 'date', options: { dateStyle: 'medium' } } },\n ]\n</script>\n\n<SvGrid data={people} {columns} sortable filterable filterMode=\"menu\" />\n```\n\n\n## Booleans\n\n`cellDataType: 'boolean'` gives the column the true/false filter rather than a\ntext box, and pairs with the checkbox editor so reading and writing agree.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n joined: string\n active: boolean\n trend: number[]\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000, joined: '2021-03-01', active: true, trend: [4, 6, 5, 9, 12, 11, 15] },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000, joined: '2019-07-15', active: true, trend: [9, 8, 11, 10, 14, 16, 15] },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000, joined: '2020-01-20', active: false, trend: [12, 10, 9, 7, 6, 6, 4] },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000, joined: '2022-09-05', active: true, trend: [3, 5, 4, 8, 7, 11, 13] },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'active', header: 'Active', width: 120,\n cellDataType: 'boolean', editorType: 'checkbox' },\n ]\n</script>\n\n<SvGrid data={people} {columns} filterable filterMode=\"menu\" editable />\n```\n\n## See also\n\n- [Provided cell editors](../editing/provided-editors.md)\n- [Filter conditions](../filtering/filter-conditions.md)\n"
4070
4077
  },
4071
4078
  {
4072
4079
  "slug": "help/cells/cell-text-selection",
@@ -4143,7 +4150,7 @@ export const docs = [
4143
4150
  "path": "docs/help/cells/spreadsheet-shell.md",
4144
4151
  "title": "The spreadsheet shell",
4145
4152
  "section": "Spreadsheet",
4146
- "markdown": "# The spreadsheet shell\n\nEverything between the ribbon and the sheet tabs, as one component.\n\n```svelte\n<script>\n import { SvSheet, createWorkbook } from '@svgrid/enterprise'\n\n const wb = createWorkbook([\n { name: 'Budget', cells: [['Line', 'Jan'], ['Rent', '2400']] },\n ])\n</script>\n\n<SvSheet workbook={wb} />\n```\n\n<div data-docs-demo=\"27-spreadsheet-ribbon\" data-height=\"640\"></div>\n\nThat is the whole required API. `<SvSheet />` with no props at all opens an\nempty single-sheet workbook, which is what \"open a spreadsheet\" means.\n\nThis page is the reference: every prop, every dialog, the file formats\nand the limits. The tutorials walk through the same ground a task at a\ntime, with a live sheet under each step: [Getting started](../sheet/start.md),\n[Formulas](../sheet/formulas.md), [Formatting](../sheet/formatting.md),\n[Data validation](../sheet/validation.md), [Files](../sheet/files.md),\n[Data tools](../sheet/data-tools.md),\n[Charts, sparklines and pictures](../sheet/charts-and-objects.md) and\n[Review and share](../sheet/review.md).\n\n<!-- tutorial:install-spreadsheet -->\n<figure class=\"docs-tutorial\" id=\"tutorial-install-spreadsheet\" data-docs-tutorial=\"install-spreadsheet\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-spreadsheet.mp4\" poster=\"/tutorials/install-spreadsheet.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Install the SvGrid spreadsheet, 56 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-spreadsheet.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/install-spreadsheet.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Install the SvGrid spreadsheet</strong> (56 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>The spreadsheet ships in the enterprise package. Install it next to the grid; it runs unlicensed for evaluation.</p>\n<p>A workbook is plain data: sheets with a name and a cells matrix. Formulas are strings that start with an equals sign.</p>\n<p>Hand it to SvSheet. That is the whole API.</p>\n<p>The result is the full surface: a ribbon, the name box and formula bar, lettered columns, sheet tabs and a status bar, with the totals already computed.</p>\n<p>It behaves like a spreadsheet. Type a formula into a cell, press Enter, and the value appears while the formula bar keeps the source.</p>\n<p>Every Excel shortcut, fill handle, number formats, conditional formatting and xlsx import and export come with it. The docs list the whole surface.</p>\n</details>\n</figure>\n<!-- /tutorial:install-spreadsheet -->\n\n## What you get\n\n| Part | Comes from |\n| --- | --- |\n| Ribbon: File, Home, Insert, Formulas, Data, Review, View | `SvSheetRibbon` |\n| Name Box and fx bar, showing the RAW text of the active cell | `SvFormulaBar` |\n| A..Z headers over the built-in 1..N row gutter | `SvGrid` |\n| Sheet tabs: switch, rename, reorder, add, duplicate, hide and unhide | `SvSheetTabs` |\n| Sum / Average / Count of the selection | the shell |\n| Every Excel shortcut | `enableSheet()` |\n\nThis is composition, not a second grid. All of it already existed and was\nbeing re-assembled by hand in four separate demos. What the component adds is\nthe wiring BETWEEN the parts, done once:\n\n- The formula bar shows the raw text while the grid shows the computed value.\n Editing in the bar edits the formula, not its result.\n- The format store is keyed by row id, so sorting cannot strand formatting on\n whatever row took that index.\n- Insert and delete go through the workbook, so references are rewritten on\n every sheet rather than only the active one.\n- The fill handle translates references, so `=B2` filled down becomes `=B3`.\n- The tab strip is told when a shortcut moved the active sheet, so\n `Ctrl+PageUp` and the tabs cannot disagree.\n- AutoSum measures its run against evaluated values, so a column of subtotals\n counts as numbers rather than as `\"=SUM(...)\"` strings. With one cell\n selected it proposes the `=SUM(...)` in the cell's editor, as Excel's\n does, so a guess that stopped a row short is corrected before Enter takes\n it; a wider selection has the sum written outright.\n- Ctrl+Z and Ctrl+Y, and the ribbon's Undo and Redo, go to the sheet the\n change was made on: a step made on Orders and undone from Summary brings\n Orders up and changes the cell back there, as Excel does, rather than\n landing on Summary's cell of the same address. Every step the grid\n records is marked with its sheet for this (`api.setHistoryTag` and\n `api.peekUndo` on the grid), the mark follows a rename, and Delete\n Sheet empties the undo list, as Excel's does. A click that selects a\n chart or a picture is not a step; a drag that moves one is.\n- A click on a sheet tab, on the strip's add button or on an entry of the\n tab menu leaves the keyboard on the cells, so the arrow keys, Ctrl+Z and\n Shift+F11 work on the sheet that just came up. The arrow keys inside the\n strip keep the focus on the tabs, as a tablist's do.\n- Formats are kept per sheet, as they are in Excel. Bold on Summary!C5 says\n nothing about Orders!C5, and the store follows a sheet through a rename.\n- The Name Box lists the workbook's defined names. Pick one and the shell\n selects what it refers to, switching sheets first when it lives elsewhere;\n type an address and press Enter to go there; type a name that is not one\n yet and it is defined for the selection, sheet-qualified and absolute as\n the Name Manager writes one, one undo. Either way focus comes back\n to the sheet, so the next keystroke edits the cell just reached. Beside it\n sit Excel's three: Cancel and Enter while a formula is being typed, and\n fx, which raises `insert-function`.\n- A formula-bar entry lands in the cell the edit started in, however the\n edit ends: Enter, or a click on another cell. It is one step in the\n grid's history, so Ctrl+Z takes it back.\n- Enter after an in-cell edit commits and moves down, Shift+Enter up, Tab\n right, as in Excel. Alt+Enter is a line break: the cell turns on Wrap\n Text and its row grows to show every line, as a wrapped row does after\n Wrap Text on the ribbon or in Format Cells; a row the fitting grew is\n back to its height once the wrap goes or the text gets shorter, while a\n row sized by hand, dragged or typed into Row Height..., stays as the\n user left it, which is Excel's auto-height rule. F4 while editing turns the\n reference at the caret through `$A$1`, `A$1`, `$A1` and back.\n- AutoComplete, as Excel's: while a cell is typed into, the text entries in\n the same column that run without a blank above and below it are matched\n against what was typed, and when exactly one fits, the rest of it appears\n selected in the cell. Typing on overwrites it, Backspace takes it away,\n and Enter or Tab accepts it in the entry's own case, so \"jo\" under \"John\n Smith\" commits \"John Smith\". Numbers, formulas and booleans are never\n offered, and two entries that both fit (\"Apple\", \"Apricot\" for \"Ap\")\n offer nothing until the typing tells them apart. `completeEntry(typed,\n entries)` is the rule on its own.\n- Formulas are typed the way Excel's are. Pointing: a click on a cell while\n a formula is being typed, in the cell or in the bar, puts that cell's\n address in at the caret rather than ending the edit, a drag puts a range,\n and a second click replaces what the last one put there; typing goes on\n from there. Formula AutoComplete: the function names a typed prefix could\n be are listed under the cell, Up and Down walk them, Tab or Enter takes\n one with the caret inside its brackets, Escape closes the list. Backspace\n on a cell opens the editor empty, so Escape gives the value back where\n Delete would have blanked it.\n- Data entry steps the way Excel's does: the Enter that ends a run of Tabs\n goes down from the column the run began in, and inside a selected block\n Enter and Tab walk the block and wrap at its edges while the block stays\n selected. Ctrl+Enter puts the entry into every cell of the block at once,\n each taking its value and format as if typed there, as one undo. A drag, Shift+click or Shift+Arrow grows the selection from\n its far corner and leaves the active cell at the anchor, white inside the\n tinted range, and the formula bar shows that cell.\n- The formula bar follows an in-cell edit as it is typed, and the cell\n follows the bar; both go back when the edit is cancelled.\n- The formula bar keeps a cell's line breaks. It shows one line until the\n chevron at its end expands it to every line the text takes at the bar's\n width, whether from a line break or from wrapping, up to six, with a\n scrollbar past that; Alt+Enter typed in the bar breaks the line there and\n expands the bar.\n- A typed `12%` is the number 0.12 shown as a percentage, `$1,200` is 1200\n shown as currency, `1,234.5` keeps its separator: the entry names a value\n and a format, and the cell takes both unless it already has a number\n format of the same kind (a time typed into a currency cell makes it a\n time cell, as it does in Excel; `5%` typed into a `0.0%` cell keeps the\n cell's decimals). `=A1*2` over a `12%` cell is 0.24. A plain number\n typed into a cell that already shows percentages is that percentage, so\n `5` in a `0%` cell is 5% rather than 500%, as Excel's automatic percent\n entry has it; `(5)` is -5, the way a statement writes a negative. A mixed\n fraction with a whole part, `3 1/2`, is 3.5 shown back as a fraction, and\n `0 1/2` is how you enter a bare half without `1/2` becoming the second of\n January; `=A1*2` over `3 1/2` is 7.\n- A date typed the American way, `3/4/2026`, `3/4/26` or `3/4`, or with\n the month spelled out, `4-Mar-2026`, `4 Mar 26`, `March 4, 2026` or\n `Mar 4` (English month names, in either order), is the sheet's\n `2026-03-04` under a date format, and a clock time, `10:30`,\n `6:00:00` or `10:30 PM`, is the fraction of a day it is under a time\n format, so the times add up. In arithmetic a date written as text is its\n day number, as a date cell is in Excel: `=A1+1` under a date is the next\n day rather than `#VALUE!`, and `=B1-A1` over two dates is the days\n between them.\n- A formula typed into a cell with no format takes the number format of\n the first cell it reads, as Excel's does: `=A1*2` under `$5.00` is\n `$10.00`, `=SUM(B2:B9)` over currency is currency, `=A1+1` under a date\n is a date and `=TODAY()+7` is a day. A formula whose function returns a\n count or a part (COUNT, LEN, YEAR and the rest) stays General, and so\n does one date taken from another, which is a number of days, the one\n exception Excel makes too.\n- File > Open reads what the other spreadsheets write, not only what this\n one wrote: a sheet's own `<cols>` or a table's filter, a row height\n LibreOffice left unflagged, `General` as the absence of a format, a row\n or a cell that carries no position of its own, an error or a boolean\n saved as a formula. Save As writes a package Excel, Google Sheets and\n LibreOffice all open; a rule it cannot spell is left out rather than\n written badly, since a file with one bad element in it is a file Excel\n offers to repair.\n- A sort reorders the rows you can see and leaves the hidden ones where\n they are, as Excel's does, so sorting a filtered list cannot drag a\n filtered-out row into view or overwrite what one holds. The status bar\n says how many stayed put. A row takes its formulas along the way a copy\n would: the relative references shift to the row's new place and the\n absolute ones stay, so `=SUM(B5:E5)` on a row that lands in row 2 reads\n `B2:E2`, and an `=IMAGE(C5)` thumbnail still shows its own row's picture.\n- `SUBTOTAL` follows the filter: `=SUBTOTAL(9, C2:C99)` under a filtered\n list totals the rows that matched, and the 101-111 codes leave out rows\n hidden by hand too. That is what a table's totals row should be written\n with; `SUM` counts everything, as it does in Excel.\n- The status bar's Average, Count and Sum are of the cells you can see: a\n filtered-out row is not in the total, which is what makes the bar worth\n reading over a filtered block. Delete and Clear Contents leave such a row\n as it is, so clearing a filtered selection cannot wipe what the filter\n hid.\n- A copy leaves out the rows a filter folded away, and the block closes up\n around them, so filtering a log and copying the block gives the rows that\n matched with nothing between them. A row hidden by hand with Hide Rows\n IS copied, as Excel copies one (the grid's `includeCollapsedRows` prop is\n how the shell tells the two apart); a hidden column is left out. The\n merges inside the block travel with a paste of everything or of the\n formats, laid over the landing from its corner, and a merged cell copied\n on its own is the whole merge, as it is in Excel.\n- Ctrl+X marks the block rather than emptying it, as Excel's cut does: the\n cells stay where they are until the paste lands, Escape leaves the sheet\n as it was, and one Ctrl+Z puts a whole move back. The ribbon's Cut and\n the menu's mean the same thing.\n- Cut and paste MOVES the cells, so every formula that read them follows:\n moving A1 to D1 rewrites `=A1*2` as `=D1*2`, across every sheet and the\n defined names, the way Excel does. A `$` makes no difference, since the\n cell itself moved; a range follows only when all of it moved, so\n `=SUM(A1:A2)` stays as it is when A1 alone goes. A copy translates\n relative references by the distance pasted, as before.\n- A typed error code is that error, as it is in Excel: a cell holding\n `#N/A` answers TRUE to `=ISNA(A1)` and carries the error into anything\n that reads it, so a placeholder row reads as missing rather than as the\n text that spells it. Case does not matter, and `'#N/A` is the text.\n- A leading apostrophe says the rest is text, as it does in Excel: `'007`\n is the text `007`, leading zero kept, and `'=A1+1` shows the formula\n rather than working it out. The apostrophe is not part of the value, so\n it is never displayed, printed, exported or compared; the formula bar\n shows it, since it is what was typed. A cell that comes back from an\n .xlsx as text keeps its prefix, so a part number survives the round trip.\n- The fill handle fills the way Excel's does: a lone value repeats, `1, 2`\n continues, `Jan` runs on to `Feb`, and a formula moves every relative\n reference by the distance dragged. Ctrl held at the release turns it the\n other way, a series into a copy and one number into a series. Double-click\n it to fill down as far as the column beside the selection has data. A number too wide for its\n column reads as `####` until the column fits it; `TRUE` and `FALSE`\n typed into a cell are booleans, centred.\n- Freeze Panes freezes everything above and left of the active cell, both\n halves, per sheet: the rows stay under the column letters and the columns\n beside the row numbers while the rest scrolls, and every frozen cell is\n still an ordinary cell. Unfreeze clears both.\n- A dragged column width or row height is one Ctrl+Z, and double-clicking\n a row number's lower edge fits the row to its wrapped text, as a\n double-click on a column letter's edge fits the column to its text.\n- Row heights are the sheet's: a row dragged taller stays that way through\n every edit, moves with its row when rows are inserted or deleted above it,\n comes back with an undo, and belongs to one sheet. The fill handle's drag\n is one undo, like Ctrl+D. Column widths move with their columns the same\n way.\n- Hide and Unhide, on the column letter and row number menus and on Ctrl+9\n (rows), Ctrl+0 (columns), Ctrl+Shift+9 and Ctrl+Shift+0: a hidden line\n keeps its letter or number and every reference to it, and takes no room,\n so the headers read A, B, D. Unhide reveals what the selection spans, or\n the hidden lines right next to it when it spans none. The cursor steps\n over hidden lines, Ctrl+Arrow lands on the last cell of a run that shows\n rather than its hidden end, and the Name Box can still reach a hidden\n cell; a hide is one Ctrl+Z, moves with an insert or delete, and belongs\n to its sheet.\n- Text spills over empty neighbours and is clipped by the first cell that\n holds something, so a title in A1 reads in full and a label beside a\n number does not run under it. Numbers never spill. The text runs only\n over neighbours with the same background as its own cell: a title on a\n banded row reads across the band, and a filled cell beside an unfilled\n one (or the other way round) keeps its text in its cell, since the run\n would drag one colour over the other.\n- General alignment: numbers right, text left, errors and booleans centred,\n unless the cell says otherwise.\n- The headers of every column and row the selection touches are shaded and\n marked with a line in the accent, the Select All corner sits top-left,\n and the status bar reads Ready on the left and Average / Count / Sum on\n the right.\n- Sheet tabs delete, insert, rename, move, duplicate, hide and unhide from\n a right-click menu, as in Excel; nothing on the tab face can remove a\n sheet, and deleting one that holds data asks first. A hidden sheet keeps\n its cells and every reference to it, Ctrl+PageUp and PageDown step over\n it, and the flag rides in the document. Duplicate copies the cells and\n everything the document keeps beside them, and the copy's table, charts,\n pictures, sparklines and PivotTables are its own: each gets a fresh name\n or identity, so working on the copy leaves the sheet it came from alone.\n- Click a column letter to select the column, a row number to select the\n row, the corner to select the sheet; Shift+click extends. Drag the edge of\n a row number to resize the row, as with a column letter.\n- Copy puts two things on the system clipboard: the text each cell shows,\n which is what a text editor expects, and an HTML table with the formats,\n the numbers behind the formatted text and the formulas, written the way\n Excel writes its own (`mso-number-format`, `x:num`), so a paste into\n Excel or Google Sheets keeps the number format, the bold and the fill. A\n paste back into the sheet gets the formula, moved by the distance it\n travelled (`=A1*2` copied from B1 to B3 reads `=A3*2`), and the cell's\n format, as Excel pastes. A cut block is moved, not translated. The block\n Ctrl+C or Ctrl+X took is outlined by Excel's marching ants until Escape,\n a typed entry, a structural edit, the paste of a cut, or the next copy; a\n Ctrl+V paste of a copy keeps them, so the block can be pasted again, and\n Enter pastes it once and drops them, as Excel's Enter does. The Copy and\n Cut buttons on the ribbon and in the cell menu are the same commands as\n the keys.\n- Paste reads what the clipboard carries. A block copied from Excel arrives\n with its values (the number, not the text `1,234.50`), its number\n formats, fonts, colours and fills, and its formulas: Excel's HTML says\n nothing about where a block was copied from, so a formula whose\n references all fall inside the block is kept and moved with the paste as\n if the block came from A1 (a totals column keeps totalling), and one that\n reaches outside the block lands as its value. A block from Google Sheets\n the same, with its R1C1 formulas turned into A1 for the cells they land\n in. Plain text lands as typed. One copied cell fills the\n selected range, and a block that fits the selection a whole number of\n times is repeated over it, as in Excel. One paste is one Ctrl+Z, formats\n included.\n- Selecting works as it does in Excel. A click on a column letter or a\n row number selects the whole line and puts the active cell on its first\n visible row or column; a drag along the letters or the numbers selects a\n run; Shift+click extends the run from the active cell's line; Ctrl+click\n adds a line beside the selection; the corner selects everything and\n leaves the active cell where it was. Shift+Arrow grows a run of whole\n lines, an arrow collapses to the active cell, and typing lands in the\n active cell, so the keyboard never leaves the sheet. Shift+Space and\n Ctrl+Space take the whole rows or columns the selection touches, Ctrl+A\n the current region then the sheet, Ctrl+Shift+8 the region alone. A\n column or row selected whole paints its letter or number in the accent,\n white on green as Excel's; a line the selection merely touches is\n tinted. While a range is dragged out the Name Box reads \"3R x 2C\".\n- The pointer reads as it does in Excel: the white cross over the cells,\n a black arrow down a column letter or along a row number, the\n double-headed arrows on the borders, the crosshair on the fill handle.\n Dragging a border shows Excel's tip beside the pointer, \"Width: 8.43 (64\n pixels)\" or \"Height: 15.00 (20 pixels)\", with a dotted guide across the\n sheet at the new edge; dragging the fill handle shows what the cell\n under the pointer will get (Feb, 7, 2026-09-18), in the source cell's\n number format.\n- A General cell shrinks a number to its column before it hashes, as\n Excel's does: the decimals go first (5.333333 reads 5.3333, then 5.33),\n then a whole part too wide turns scientific (1.76E+04). A formatted\n number hashes as soon as its format does not fit, since the format said\n what to show.\n- Right-click a column letter or a row number for its menu: the column or\n row is selected first, unless it was already inside the selection, and the\n menu offers Insert, Delete and Column Width... or Row Height... for that\n axis alone, as Excel's does.\n- Right-click a cell for Excel's menu: Cut, Copy, Paste, Paste Special,\n Insert / Delete Rows and Columns, Clear Contents, Clear Formats, Merge &\n Center or Unmerge, the comment entries, Format Cells, each with the\n ribbon's icon for it so the two read as one set. Structure goes through\n the sheet's own insert and delete, which rewrite every formula the\n change reaches, move the formats with their cells, and undo as one step.\n- The in-cell editor opens with the caret after the text and widens over\n the cells to its right as a long formula is typed. While a formula is\n being written, in the cell or in the formula bar, every range it refers\n to is outlined in its own colour, one rectangle per range, and the\n reference in the text takes the same colour, in the cell and in the bar:\n Excel's range finder. Colours go by distinct range in order of first\n appearance, so `B5:B8` twice is one colour and `b5`, `$B$5` and `B5` are\n one range. `referenceSpans(text)` is the rule, exported.\n\n## The ribbon is Excel's ribbon\n\nThe layout is Excel's, down to the geometry: a group is three rows of 24px,\na **large** button (icon over label) fills a column of that height and\n**small** buttons stack three to a column, so Cut / Copy / Format Painter\nline up beside a tall Paste, as icons alone the way Excel draws them. The\nHome tab runs Clipboard, Font, Alignment, Number, Cells, Styles, Editing in\nExcel's order, with Excel's names on the buttons (Undo and Redo open the\nEditing group as a column of small icons), a dialog-box launcher in the corner of the groups that have a\ndialog, and the group label underneath, which is what makes a ribbon read\nas a ribbon rather than a toolbar.\n\nSome things worth knowing are in there because Excel users reach for them\nwithout looking:\n\n- **Paste is Excel's split button.** The icon pastes; \"Paste\" with its\n chevron under it opens the kinds: Paste, Formulas, Values, Formatting,\n Transpose, and Paste Special... at the end, each raised as an action\n (`paste-formulas`, `paste-values`, `paste-formats`, `paste-transpose`,\n `paste-special`) that the shell answers through its Paste Special engine.\n- **Format Painter is the brush.** Select the cells whose look you want,\n press the brush, and the next click or drag takes it, as one undo: a\n single cell takes the whole block from that corner, a dragged range has\n the block tiled over it, and a cell whose source had no format loses its\n own, as in Excel. The button reads pressed while the brush is up, the\n pointer is a brush over the cells, Enter paints onto the current\n selection, and Escape or the button again puts it down. Raised as\n `format-painter`.\n- **Fill Colour, Font Colour and Borders are split buttons.** The face\n re-applies the last pick (yellow and red to start, as in Excel); the arrow\n opens the menu. The colour menus are Excel's picker: the theme row, five\n tints and shades under each colour, then the standard colours. Borders\n applies to the *selection* the way Excel does: Bottom Border lines the\n bottom edge of the block, All Borders lines every cell, Outside frames it.\n- **The combos show the cell.** Font, size and number format read the\n active cell, so a 17px title says 17 even though 17 is not on the list.\n- **Every format change is one Ctrl+Z**, through the grid's own history,\n so the ribbon's Undo lights up after Bold and undoing it puts the cell's\n whole entry back. A command that writes cells and formats together is\n still one press.\n- **Paste pastes.** It reads the system clipboard the way Ctrl+V does, so\n it needs a secure context; elsewhere it stays quiet, as Ctrl+V does.\n\n**It is responsive the way Excel's is, in Excel's two steps.** When the\nband is narrower than its groups, the groups go compact from the right\nfirst: their small buttons keep the icon and drop the label, so Fill Down\nbecomes an arrow and Wrap Text a glyph, with the title still in the\ntooltip. Only when every group is compact and the band is still too\nnarrow do groups fold, again from the right, into one large button each\n(the group's own icon over its name and a chevron) that opens the group in\na dropdown. Nothing is hidden, only folded; widen the window and it all\ncomes back in the same order. Every width is measured off an invisible\ncopy of the band, never estimated, so a group folds exactly when it would\nnot fit. On a phone the folded band scrolls sideways.\n\n**It collapses the way Excel's does.** Double-click a tab, press the\nchevron at the band's end, or Ctrl+F1, and the band goes away leaving the\ntab row. A click on a tab then shows that tab's band over the sheet until\na command runs, Escape, or a click elsewhere; the pin at its end, a\ndouble-click or Ctrl+F1 brings the band back for good. The state is the\n`collapsed` prop of `SvSheetRibbon`, bindable, and the action is raised\nas `toggle-ribbon` so `onAction` can watch or take it over.\n\n## The ribbon and the keyboard are the same thing\n\nClick a bold cell and the Bold button lights up. Press `Ctrl+B` and it goes\nout. Neither is reacting to the other: both call one function in\n`sheet/shortcuts.ts`, and the button's pressed state is read from the format\nstore rather than tracked separately.\n\nThat is why `sheet/ribbon.ts` is a table of data rather than markup. An item\neither carries a `run` that calls a real action, or an `emits` naming\nsomething the ribbon cannot do alone:\n\n```ts\n{ id: 'bold', label: 'B', title: 'Bold', keys: 'Ctrl+B',\n kind: 'toggle',\n run: (cmd) => toggleFormat(cmd, 'bold'),\n isOn: (cmd) => everyCellHas(cmd, (e) => e?.bold === true) }\n```\n\nButtons that open a dialog set `emits` and raise an action. Every action\nreaches your `onAction` handler first; return `true` to take it over,\notherwise the shell's own dialog opens:\n\n```svelte\n<SvSheet\n {wb}\n onAction={(action, cmd) => {\n if (action === 'goal-seek') { openMyGoalSeekDialog(cmd); return true }\n }}\n/>\n```\n\nThe shell answers everything on the ribbon itself: `recalculate`,\n`toggle-formulas` (Excel's `Ctrl+` `` ` ``), `toggle-filter`, `sort-asc` /\n`sort-desc` / `sort-custom`, and the dialogs listed below. One button has\nnothing behind it in the library, Insert > Table, and is left off the\nribbon unless `extras={['insert-table']}` says the application answers it.\n\n## Props\n\n| Prop | Default | What it does |\n| --- | --- | --- |\n| `document` | one built from `workbook` or `data` | The sheet document: the workbook plus every sheet's formats, sizes, hidden lines, frozen panes, comments, protection, validation and conditional formatting. Build it with `createSheetDocument` to save, restore or listen to it outside the component. |\n| `workbook` | a new empty one | The workbook to edit, when not supplying a document. |\n| `data` | | Seed sheets, when not supplying a workbook. |\n| `rows` / `columns` | 50 / 12 | Minimum size, so a sparse sheet still looks like a sheet. |\n| `height` | 420 | Grid viewport height. |\n| `columnWidth` | 96 | Default column width. |\n| `rowHeight` | 22 | Row height. Excel's is 20 at 100%; 22 fits the 13px cell font. |\n| `look` | `'theme'` | `theme` paints the shell with the host's `--sg-*` tokens, so it follows the app's theme; `excel` pins Excel's own palette and geometry, light or dark with the page. |\n| `columnWidths` | | Per-column overrides keyed by letter: `{ A: 150 }`. |\n| `formats` | | Cell formats keyed by address: `{ B2: { bold: true } }` for the sheet active at mount, `{ 'Orders!F2': ... }` for another sheet. |\n| `showRibbon` / `showFormulaBar` / `showTabs` / `showStatusBar` | `true` | Hide any part of the chrome. |\n| `onAction` | | Every ribbon action, first; return `true` to take one over. |\n| `extras` | `[]` | Whether to show Insert > Table, because the application answers it. |\n| `commentAuthor` | | Who signs a new comment and a reply, with the time; unset, a new comment is a plain note. |\n| `localization` | | `{ locale, text }`: the language of the chrome. `text` overrides any of the shell's strings (see [Localisation](#localisation)); `locale` formats the status bar's numbers and reaches the grid underneath. |\n| `onReady` | | The `SvGridApi` and the document, once the grid has mounted. |\n| `onChange` | | Every change the user lands, once per tick: `cells`, `formats`, `sizes`, `hidden`, `freeze`, `sheets`, `comments`, `protection`, `page-setup`, `objects`, `validation`, `conditional-formats`, `structure` (with the insert or delete), `restore`. Undo and redo report too. |\n\n`formats` matters more than it looks: without it a sheet can only be\nformatted by hand after it loads, which makes it impossible to ship a\ndocument that opens the way it was saved.\n\nA bare address formats the sheet that is active when the shell mounts.\nQualify it to reach another sheet, the way a formula would - `Orders!F2`,\nor `'Price list'!C2` when the name has a space:\n\n```svelte\n<SvSheet\n workbook={wb}\n columnWidths={{ A: 150 }}\n formats={{\n A1: { bold: true, fill: '#e2e8f0', color: '#0f172a' },\n E2: { numFmt: '#,##0' },\n B13: { numFmt: '0.0%' },\n 'Orders!F2': { numFmt: '$#,##0.00' },\n }}\n/>\n```\n\n<div data-docs-demo=\"456-sales-report-workbook\" data-height=\"600\"></div>\n\n## Files\n\nThe File tab is the part of Excel's a document in a page can do: New,\nOpen, Save As, Save As ODS, Save As XLS, Export CSV and Print. Open takes an\n.xlsx, an .ods, an .xls or a .csv from disk - the kind is read from the\nbytes, not from the name - and replaces\nthe document with everything the file holds that the document keeps\n(cells with their formulas, formats, widths and heights, hidden lines and\nsheets, frozen panes, merges, the filter with its criteria and the rows it\nfolds, validation, conditional\nformatting, protection, comments, names, the active sheet); Save As\ndownloads the document as an .xlsx that Excel, Google Sheets and LibreOffice\nopen with the same parts; Save As ODS writes the same document in\nOpenDocument, the format LibreOffice Calc saves by default and one of the\nfiles Google Sheets hands back, with the cells, their formulas in ODF's own\nreference grammar, the number formats, the looks, widths and heights, merges,\nhidden lines, links and notes; Save As XLS writes an Excel 97-2003 workbook,\nthe binary .xls that every Excel since 1997 opens and that a bank or an ERP\nexport still often is; Export CSV downloads the active sheet as its cells\nshow, and Open reads such a file back: the separator is guessed, so a comma,\na semicolon or a tab file all read as themselves, a quoted field keeps its\ncommas and line breaks, and a field that reads as a percentage, a currency\namount or a grouped number lands as the number with the format it implies.\nA CSV field is never read as a formula, as it is not in Excel;\nNew starts over with one empty sheet, asking first when the sheets hold\nanything; Print (Ctrl+P) opens the active sheet in the browser's print\ndialog as its Page Layout says (below). `documentToXlsx` and\n`documentFromXlsx` are the two halves, in `@svgrid/enterprise/sheet`, and\nneed the `jszip` peer; `documentToOds` and `documentFromOds` are the same\npair for OpenDocument; `documentToXls` and `documentFromXls` are the pair for\nthe old binary format, and need nothing, since an .xls is not a zip; and\n`documentFromFile` takes any of the four and decides by the bytes. `csvText`\nand `sheetStateFromCsv` are the CSV pair, and need nothing either.\n\nWhat an .ods carries is the document this package keeps, not every corner of\neither format: cells and their formulas (translated both ways, so\n`=SUM(A1:A3)` here is `of:=SUM([.A1:.A3])` there, and a structured reference\nbecomes the rectangle it names, since ODF has none), number formats, the cell\nlooks, column widths and row heights, merges, hidden rows and columns, the\nfilter with its values and conditions (a date period or a colour has no ODF\nspelling, so its rows go out hidden instead), hyperlinks, notes, defined\nnames and sheet protection. Charts,\nimages, sparklines, validation, conditional formatting and frozen\npanes travel in the .xlsx and not yet in the .ods. A PivotTable travels\nin every format as the cells it wrote, never as a definition: the .xlsx\nhas no pivot part, so Excel sees the block, not a pivot to refresh.\n\nThe .xls is the oldest of the three and carries the least. Cells and their\nformulas travel, with the formulas as the RPN tokens Excel 97 stored rather\nthan as text: a function that format never had (XLOOKUP, LET, TEXTJOIN, a\nstructured reference) has no token, so such a cell goes out as the value it\nworked out, and a workbook opened from an .xls reads those the same way\nround - Excel writes `_xlfn.XLOOKUP` as a defined name and calls it, and\nthat name turns back into the function here. Number formats, fonts, colours,\nfills, borders, alignment, column widths, row heights, hidden rows, columns\nand sheets, merges, frozen panes, sheet protection and defined names travel;\ncharts, pictures, sparklines, validation, conditional formatting and\ncomments do not, since the format kept them somewhere this reader does not\ngo. A filter goes out as the rows it folds, hidden, and nothing else: the\nfile shows the same rows, and the arrows do not come back from it. The sheet is 65,536 rows by 256 columns, and anything past that edge is\nleft out rather than written wrong.\n\nAn app that keeps its workbooks somewhere other than the user's disk takes\nthe actions over through `onAction` (`file-open`, `file-save-xlsx`,\n`file-save-ods`, `file-save-xls`, `file-new`, `file-export-csv`,\n`file-print`) and calls the component's\nown methods: `open(file)` replaces the document with an .xlsx, .ods, .xls or\n.csv Blob, `toXlsx()` returns the document as a Blob, `toOds()` the same\ndocument in OpenDocument, `toXls()` it as an Excel 97-2003 workbook,\n`toCsv()` the active sheet as text, `newWorkbook()` empties it, `print()` opens the print dialog and\n`printHtml()` returns the page it would print, for an app that prints\nits own way.\n\n```svelte\n<SvSheet bind:this={sheet} onAction={(action) => {\n if (action === 'file-save-xlsx') { sheet.toXlsx().then((blob) => upload(blob)); return true }\n if (action === 'file-open') { pickFromServer().then((blob) => sheet.open(blob)); return true }\n}} />\n```\n\nDates are `yyyy-mm-dd` text in the sheet and serial numbers in the file:\na text date goes out as a serial under a date format, and a serial under a\ndate format comes back as text. Tables, charts and images are not carried\neither way.\n\n## Page Layout and Print\n\nThe Page Layout tab is Excel's Page Setup group and Sheet Options, per\nsheet and part of the document: Margins (Normal, Narrow, Wide),\nOrientation, Size (Letter, Legal, Tabloid, A3, A4, A5), Print Area (Set\ntakes the selection, Clear puts the whole sheet back), Print Titles (the\nrows that repeat at the top of every page), the group's launcher opening\nthe Page Setup dialog with all of it and the scale, and Print Gridlines\nand Print Headings. Each change is one undo and reports\n`{ kind: 'page-setup' }`.\n\nFile > Print (Ctrl+P) lays the sheet out as one HTML document and hands it\nto the browser's own print engine, so fonts, CJK and RTL come out right\nand nothing is bundled: the print area or the used range, column widths\nand row heights as the sheet shows them, hidden lines and the rows a filter\nfolds away left out, merges as\nspans, every cell as it shows with its format and its conditional style,\nthe title rows repeated on each page, the sparklines drawn in their cells\nand the charts and pictures hung from theirs, a table's header and banding\nin the colours it wears on the sheet, gridlines and headings when\nasked, and the orientation, paper and margins in `@page`. \"Save as PDF\" in that\ndialog is the PDF. The setup rides in `getState()` as `pageSetup` and in\nthe xlsx as `pageSetup`, `pageMargins`, `printOptions` and Excel's own\n`Print_Area` and `Print_Titles` names, both ways.\n\n## Saving and restoring\n\nEverything the user does lands in the sheet's document: the workbook (raw\ntext, formulas as typed, names, the active sheet), and per sheet the\nformats (locked flags included), column widths, row heights, hidden lines,\nfrozen panes, comments, the protection flag with its allow list and edit\nranges, the page setup, the charts and pictures on the sheet, the\nvalidation rules and the conditional formatting rules.\n`getState()` returns it as plain JSON and\n`setState()` puts it back into the same workbook object, so an autosave\nis an `onChange` handler and a reload is one call after mount:\n\n```svelte\n<script lang=\"ts\">\n import { SvSheet, type SheetState } from '@svgrid/enterprise'\n\n let sheet = $state<SvSheet>()\n let timer: ReturnType<typeof setTimeout> | undefined\n\n function autosave() {\n clearTimeout(timer)\n timer = setTimeout(() => {\n if (sheet) localStorage.setItem('budget', JSON.stringify(sheet.getState()))\n }, 400)\n }\n\n const saved = localStorage.getItem('budget')\n</script>\n\n<SvSheet\n bind:this={sheet}\n onChange={autosave}\n onReady={() => { if (saved) sheet?.setState(JSON.parse(saved) as SheetState) }}\n/>\n```\n\n### Autosave to a server\n\nThe same handler with a `fetch` in it, plus the three things a network\nmakes you think about:\n\n```ts\nlet timer: ReturnType<typeof setTimeout> | undefined\nlet saving: Promise<unknown> = Promise.resolve()\nlet revision = 0\n\nfunction autosave() {\n clearTimeout(timer)\n // Coalesce a burst of edits into one request. onChange already coalesces\n // per tick; this coalesces per pause in the typing.\n timer = setTimeout(() => {\n const state = sheet!.getState()\n // One request at a time, in order: a save that overtakes its\n // predecessor writes an older document over a newer one.\n saving = saving.then(async () => {\n const response = await fetch(`/api/books/${id}`, {\n method: 'PUT',\n headers: { 'content-type': 'application/json', 'if-match': String(revision) },\n body: JSON.stringify(state),\n })\n if (response.status === 412) return onSomeoneElseSaved()\n revision = Number(response.headers.get('etag') ?? revision + 1)\n })\n }, 800)\n}\n```\n\n- **Debounce, then serialise.** `getState()` is a full snapshot, so the\n cost is in the transfer, not the call. One request per pause, and never\n two in flight, or a slow request lands after a fast one and the older\n document wins.\n- **Carry a revision.** Send what you last saw and let the server refuse a\n save built on a stale document, rather than discovering the overwrite\n later. `412` is the moment to reload, or to ask.\n- **Send changes instead, when there are two people.** A whole state per\n keystroke is fine for one user and wrong for two. `createDeltaStream`\n (see \"Two people on one sheet\") gives a delta per change, small enough to\n send as it happens, and applies one from someone else without a reload.\n\nA save that runs on a timer rather than on `onChange` is worse, not\nbetter: it saves when nothing changed and misses the tab closing. If the\ntab closing matters, save on `visibilitychange` as well, with the same\nsnapshot.\n\n`onChange` is called once per tick with every reason since the last call,\nso a paste of forty cells is one call, and it says what changed\n(`reasons.some((r) => r.kind === 'structure')` for an insert or delete, with\nthe edit on the reason). A change to the workbook's own parts rather than a\nsheet's, its names or its calculation settings, reports `workbook`; a table\nreports `tables`. A host writing those straight to the workbook raises the\nsame reason with `document.changed({ kind: 'workbook' })`, which is what\nputs it on a delta stream. `setState` clears the grid's undo history, since\nnone of it describes the restored sheet, and reports a single `restore`.\n\nThe same document is available outside the component: build it with\n`createSheetDocument({ sheets })` or `createSheetDocument({ state })`, pass\nit as `document`, and call `document.getState()`, `document.setState()` and\n`document.subscribe()` on it directly. A document built this way can be\nsaved and restored in Node, with no component at all.\n\nThe shape, `SheetState` (exported with `SheetStateEntry`, `ValidationRule`\nand `CfRule`):\n\n```ts\n{\n version: 1,\n workbook: {\n sheets: [{ name, cells }], active, names,\n tables: [{ name: 'Orders', sheet: 'Budget', headerRow: 0, firstCol: 0, lastCol: 4, lastRow: 12, hasTotals: false }],\n iteration: { enabled: true, maxIterations: 100, maxChange: 0.001 },\n },\n sheets: {\n Budget: {\n formats: { 'r0 A': { bold: true, numFmt: '#,##0', locked: false } },\n columnWidths: { A: 150 },\n rowHeights: [[4, 44]],\n hidden: { rows: [7], cols: [] },\n freeze: { rows: 1, cols: 0 },\n comments: { r1: { B: 'Check with finance' }, r4: { D: { text: 'Over budget?', author: 'Ana', at: '2026-03-04T10:00:00.000Z', replies: [{ text: 'By 3%', author: 'Ben', at: '2026-03-04T11:00:00.000Z' }] } } },\n protected: false,\n protection: { allow: { formatRows: true }, ranges: [{ id, title: 'Inputs', rects: [[1, 1, 9, 1]] }] },\n pageSetup: { orientation: 'landscape', paper: 'A4', margins: { top: 0.75, bottom: 0.75, left: 0.7, right: 0.7, header: 0.3, footer: 0.3 }, printArea: null, printTitleRows: [0, 0], gridlines: false, headings: false, scale: 100 },\n objects: [{ id, kind: 'chart', anchor: { row: 12, col: 1, dx: 8, dy: 8, width: 420, height: 260 }, range: [0, 0, 11, 2], type: 'bar', headers: true, series: 'columns' }],\n sparklines: [{ id, location: [1, 6, 9, 6], data: [1, 1, 9, 5], type: 'line', color: '#2563eb' }],\n pivots: [{ id, source: [0, 0, 200, 3], target: { row: 0, col: 8 }, rows: ['Region'], cols: [], values: [{ field: 'Amount', agg: 'sum' }] }],\n links: { r1: { B: { target: 'https://example.com', tip: 'The spec' } } },\n merges: [],\n validation: [{ id, rects, allow: 'whole', operator: 'between', value1: '1', value2: '10', ignoreBlank: true, inCellDropdown: false, alert: { style: 'stop' } }],\n conditionalFormats: [{ id, rects, kind: 'cellIs', operator: 'greater', value1: '50000', style: { fill: '#FFC7CE', color: '#9C0006' } }],\n },\n },\n}\n```\n\nRows are `r` plus the 0-based index and columns are letters, a format key is the two with a\nspace between; rectangles are `[minRow, minCol, maxRow, maxCol]`,\n0-based. A hidden row keeps the height it had before it was hidden, not\nthe 0 it shows while hidden, so an Unhide after a restore brings it back\nat its size.\n\n## A component, not a mode\n\nKanban, the scheduler and charts are modes of `<SvGrid>` (`board`,\n`scheduler`, `chart`) because they are different pictures of the same\nrows: the grid keeps the data, the row model, sorting and filtering, and the\nmode draws cards, a calendar or a chart from them. The spreadsheet is the\nother way round: the same picture, a table, over a different data model. It\nowns a document (formulas, address-keyed formats, comments, rules) and uses\nthe grid as its rendering primitive, which is why it is a component that\ncomposes `<SvGrid>` rather than a prop on it. `installEnterprise()` enables\nit along with everything else; there is nothing extra to register.\n\n## Rows into a sheet\n\n`sheetCellsFromRows(rows, fields, { totals })` turns an array of records\ninto the cells `data` or `createWorkbook` take: a header row of labels, a\nrow per record with each value as the text the engine reads (a number as\nits digits, a boolean as `TRUE` or `FALSE`, a `Date` as `yyyy-mm-dd`,\nnothing for null), and with `totals` a last row with `=SUM(...)` under\nevery column that held a number. It is how a grid's rows, or an API's,\nbecome a sheet the user can add formulas to; Studio's Spreadsheet block\nis this over the screen's dataset.\n\n```svelte\n<script lang=\"ts\">\n import { SvSheet, sheetCellsFromRows } from '@svgrid/enterprise'\n\n const cells = sheetCellsFromRows(orders, [{ field: 'item', label: 'Item' }, 'qty', { field: 'price', label: 'Price' }], { totals: true })\n</script>\n\n<SvSheet data={[{ name: 'Orders', cells }]} />\n```\n\n## Writing to the workbook from outside\n\nEvery edit made through the shell repaints it: typing, the ribbon, a\nshortcut, the fill handle, and `cmd.setCellValue` from an `onAction`\nhandler, which is also how a dialog should write a cell - the write lands in\nthe grid's undo history and the shell sees it.\n\nA write the shell cannot see does not repaint: a Name Manager redefining a\nname, an import replacing a sheet, a solver applying its answer with\n`wb.setRaw`. Call `refresh()` on the component afterwards:\n\n```svelte\n<script>\n let sheet\n</script>\n\n<SvSheet bind:this={sheet} workbook={wb} onAction={(action, cmd) => {\n if (action === 'name-manager') { openNameManager(wb.names, () => sheet.refresh()); return true }\n}} />\n```\n\n`act(action)` runs a ribbon action as if its button had been clicked\n(`sheet.act('sort-asc')`, `sheet.act('circle-invalid')`), for a host with\nchrome of its own; what the ribbon would raise for `onAction` is raised\nhere too.\n\nThe shell carries Goal Seek, Text to Columns, Remove Duplicates and the\nName Manager itself. The demos below each replace one with a dialog of\ntheir own, returning `true` from `onAction`, and show the write paths a\nreplacement should use: Goal Seek applies through `cmd.setCellValue`, Text\nto Columns and Remove Duplicates write inside `cmd.batch` so each is one\nCtrl+Z, and the Name Manager calls `refresh()`.\n\n<div data-docs-demo=\"457-goal-seek\" data-height=\"600\"></div>\n\n<div data-docs-demo=\"458-data-cleanup\" data-height=\"560\"></div>\n\n## Theming\n\nEvery part of the shell is painted with the grid's `--sg-*` tokens, so\nby default it follows the app's theme the way the grid does: the accent\non the selection and the active tab, the header and ribbon grounds, the\nfont and the corner radius are the host's. The icons are one set of line\nglyphs drawn in the text colour, black on a light ribbon and white on a\ndark one, as Excel's monochrome set is; the only colour on the ribbon is\nthe bar under Fill Colour and Font Colour, which is the colour the button\nwill apply.\n\n`look=\"excel\"` pins Excel's palette instead: the shell sets the tokens on\nits own root to Office's light theme, or its dark theme when the page is\ndark, so it reads as Excel whatever theme the app around it runs, and the\ngrid inside picks the same tokens up. Nothing else changes: the geometry,\nthe icons and the layout are the same either way.\n\n## Using the parts separately\n\n`SvSheet` is a composition of exported components, so anything it does you\ncan do by hand when you want a different arrangement:\n\n```svelte\n<SvSheetRibbon cmd={() => api?.getCommandContext() ?? null} onChange={refresh} />\n<SvFormulaBar {active} value={raw} onCommit={commit} />\n<SvGrid ... />\n<SvSheetTabs workbook={wb} {version} onChange={refresh} />\n```\n\n`api.getCommandContext()` is the seam that makes this work. A keyboard\ncommand is handed a context on every keystroke; a ribbon button has no\nkeystroke to ride in on, and without it every action would have to be\nre-implemented against the public api and kept in step by hand.\n\nFormulas > Formula Auditing has Excel's arrows: Trace Precedents draws\na blue arrow from each cell the active formula reads to the formula, a\ndot on the cell read and an arrowhead on the formula, and pressing it\nagain traces one more level out; Trace Dependents draws the arrows the\nother way, to the formulas that read the active cell, every formula in\nthe workbook counted whether or not it has been on screen; Remove Arrows\nclears them, as does a switch to another sheet. Cells on other sheets are\nleft out, since there is nothing on this sheet to draw to. The arrows\nare measured from the rendered cells and follow every scroll and\nrepaint; they are not part of the document. `wb.precedents(sheet, row,\ncol)` and `wb.dependents(sheet, row, col)` are the two readings on the\nworkbook. Raised as `trace-precedents`, `trace-dependents` and\n`remove-arrows`.\n\nThe same group carries the two dialogs that answer \"why does this cell say\nthat?\". **Evaluate Formula** shows the active cell's formula with one part\nunderlined, and each click on Evaluate replaces that part with what it is\nworth, until the formula has collapsed into the cell's answer; Step Back and\nRestart walk it again. A branch of an `IF` that is not taken is never\nevaluated, so it never reports an error the cell does not have, and a range\nsuch as `A1:A9` stays where it is because a block is not a value.\n\n**Error Checking** walks the cells on the sheet worth a second look, one at\na time, with the selection following: every formula whose value is an error,\nsaid in a sentence rather than a code, and every formula that breaks the\npattern of the ones above and below it. That second check is deliberately\nquiet. A formula is only called the odd one out when the cells above and\nbelow it both hold formulas, those two agree with each other once\ntranslated, and this one does not, which is the shape of a column filled\ndown and then broken in the middle. Show Calculation Steps hands the cell\nstraight to Evaluate Formula.\n\nBoth are engine functions first, so a save-time check or a report can use\nthem with no browser:\n\n```ts\nimport { evaluationSteps, checkSheet } from '@svgrid/enterprise'\n\nconst steps = evaluationSteps(parseFormula(text), (part) => wb.evaluateText(sheet, part))\nconst problems = checkSheet(sheet, {\n rowCount: () => wb.rowCount(sheet), colCount: () => wb.colCount(sheet),\n getRaw: (r, c) => wb.getRaw(sheet, r, c), getValue: (r, c) => wb.getValue(sheet, r, c),\n})\n```\n\nRaised as `evaluate-formula` and `error-checking`.\n\n## The dialogs\n\nFour of Excel's dialogs are the shell's own, so nothing on the ribbon or in\nthe cell menu is a button that does nothing:\n\n| Dialog | Opens from |\n| ------ | ---------- |\n| Find and Replace | `Ctrl+F` or `Ctrl+H`, Find & Select on the ribbon. Find Next, Find All, Replace, Replace All; match case, whole cell, look in values or formulas. Replace All is one undo. |\n| Paste Special | `Ctrl+Shift+V`, the Clipboard group's launcher, the last entry under the Paste arrow, the cell menu. All / Formulas / Values / Formats, Add / Subtract / Multiply / Divide, Skip blanks, Transpose. Works on what Ctrl+C took from the sheet. |\n| Chart | A double-click on a chart, or Insert > Setup while one is selected. Type (Column, Line, Area, Pie, Scatter), title, series in columns or rows, whether the first row and column are labels, stacking, a trendline over every series and one series on a secondary axis; Delete removes the chart. |\n| Page Setup | Page Layout > Print Titles, the Page Setup group's launcher. Orientation, paper, margins, scale, print area, rows to repeat at top, gridlines and headings; Print... applies and prints. |\n| Protect Sheet | Review > Protect Sheet. Excel's \"allow all users of this worksheet to\" list; OK protects with what is ticked. |\n| Allow Edit Ranges | Review > Allow Edit Ranges. Titled blocks that take an edit on a protected sheet; New over the selection, Modify, Delete, Protect Sheet... |\n| Format Cells | `Ctrl+1`, the launchers on the Font, Alignment and Number groups, the cell menu, and the end of Home > Cells > Format. Number (category, decimals, separator, the Accounting symbol, the Special type, custom code, live sample), Alignment (horizontal, vertical top/middle/bottom, wrap, indent), Font, Border presets, Fill, Protection. Opens on the active cell's format and applies only what was changed to the whole selection, as one undo. |\n| Insert Function | the `fx` button. Search or pick a category, read the signature and what the function does; OK starts the cell on `=NAME(` with the caret inside. |\n| Name Manager | Formulas > Name Manager, `Ctrl+F3`. Every defined name with what it refers to and its value; edit, delete, add. |\n| Goal Seek | Data > Goal Seek. Set a formula cell to a value by changing one input; the status page shows the answer and OK keeps it as one undo. |\n| Create Table | Insert > Table, `Ctrl+T`, and again on a cell inside a table, which is how one is renamed, resized or restyled. The range (the selection, or the region around the active cell), the name its columns are read by, whether the first row is the header, a totals row, and the styles gallery. A totals row asked for is ADDED under the data, as Excel's Total Row is, with Total in the first column and `=SUBTOTAL(109,[Last column])` in the last; unticked, it goes. A structured reference is a range to SUBTOTAL, so a totals row's formulas skip the rows a filter folds. Insert > Table Styles opens the same dialog on the table the cursor is in. |\n| Evaluate Formula | Formulas > Evaluate Formula. The active cell's formula with the next part underlined; Evaluate replaces it with its value, Step Back and Restart walk it again. |\n| Error Checking | Formulas > Error Checking. Every cell on the sheet that reports an error, and every formula that breaks its column's pattern, walked with Previous and Next; Show Calculation Steps opens Evaluate Formula on the cell. |\n| Calculation Options | Formulas > Calculation Options. Excel's Enable iterative calculation, with the maximum passes and the smallest change worth another one; OK recalculates, so a circular reference goes from #CYCLE! to its fixed point, or back. See Iterative calculation below. |\n| Sort | Data > Sort. A level per key, each a column (named from the header row when \"My data has headers\" is on; the guess looks across every column of the block, so a Name column over names still reads as a header when the Pay column beside it is text over numbers, as Excel guesses it), what to sort on and an order; Add Level and Delete Level; the block is the selection or the region around the active cell. Numbers sort before text, blanks go last, ties keep their order, formats and one-row merges ride with their rows, and it is one undo. A level can sort on the cell colour or the font colour instead of the value: the list offers the colours that column carries, and the one picked goes On Top or On Bottom while every other row keeps its order, which is Excel's model, because two colours are not greater or lesser than one another. Sort A to Z and Z to A beside it sort on the active cell's column. |\n| Text to Columns | Data > Text to Columns. The delimiter is guessed from the column, the preview shows the split, Finish writes it as one undo. |\n| Remove Duplicates | Data > Remove Duplicates. Tick the columns that decide a duplicate, say whether the first row is headers; the count goes to the status bar. |\n| Data Validation | Data > Data Validation. Settings (Allow, Data, the bounds or the source, Ignore blank, In-cell dropdown) and Error Alert (Style, Title, Message); OK puts one rule over the selection, Clear All removes it. See Data validation below. |\n| Conditional formatting | Home > Styles > Conditional Formatting: Greater Than..., Less Than..., Between..., Equal To..., Text that Contains..., Duplicate Values..., Top 10 Items..., Bottom 10 Items..., Above Average..., Below Average... and New Rule > Use a Formula... each open the small dialog (the value or values, or the formula, and the \"with\" style); Manage Rules... opens the Rules Manager. See Conditional formatting below. |\n\nHome > Cells > Insert and Delete are Excel's split buttons. The face takes\nthe selection's axis: a whole column selected inserts or deletes columns,\nanything else rows, which is what Excel does with a cell selected. The\narrow spells the axis out, so a column goes in from a cell without\nselecting the column first: Insert Sheet Rows, Insert Sheet Columns and\nInsert Sheet (Shift+F11); Delete Sheet Rows, Delete Sheet Columns and\nDelete Sheet, which asks the tab menu's question first when the sheet\nholds anything (raised as `delete-sheet`). The cell menu and the column\nand row header menus offer the same Insert and Delete for their axis, and\nCtrl+Shift++ and Ctrl+- take a whole row or column selection.\n\nHome > Cells > Format is Excel's menu: Row Height..., AutoFit Row Height,\nColumn Width... and AutoFit Column Width for the rows and columns the\nselection spans (`row-height`, `autofit-rows`, `column-width`,\n`autofit-columns`; an AutoFit is one undo), Hide Rows, Hide Columns, Unhide\nRows and Unhide Columns with their keys, Lock Cell under Protection (a\ntoggle, lit while the active cell is locked, raised as `toggle-lock`), and\nFormat Cells... at the end. A ribbon item of kind `dropdown` is that shape:\nthe face opens the list and every entry raises its own action.\n\n### AutoFilter\n\nExcel's Filter: Ctrl+Shift+L, Home > Editing > Filter or Data > Filter puts\nan arrow on every header cell of the current region (the block around the\nactive cell, its first row the headers; a selected range is the region\ninstead). An arrow drops Excel's menu: Sort A to Z and Z to A (Smallest to\nLargest on a column of numbers), Clear Filter From the column, Filter by\nColor when the column's cells carry more than one fill (its own or a\nconditional format's, No Fill included), Date Filters on a column of\ndates (Today, This Week, Last Month, This Quarter, Year to Date and the\nrest, or Equals, Before, After and Between with a typed date; weeks run\nSunday to Saturday), Top 10 on a column of numbers (top or bottom, so\nmany items or so many percent, counted over the whole column), Text\nFilters or Number Filters (equals, does not equal, begins with, contains,\ngreater than, between and the rest, two of them joined with And or Or), a\nsearch box, (Select All) and the column's values with their counts, the\nblanks last. A search narrows the list and OK (or Enter in the box)\napplies what it shows, as Excel does; Alt+Down on a header cell drops the\nmenu. OK folds the rows that fail; the arrow turns into a funnel,\nthe row numbers of the region turn blue and the status bar reads \"N of M\nrecords found\". A folded row is the filter's, not a hidden row: Unhide\nleaves it, `getState().hidden` leaves it out, and Filter off (Ctrl+Shift+L\nagain) shows it while a row hidden by hand stays hidden. The rows are\nworked out again after every change, so a formula that drops out of a\nNumber Filter folds away and one that comes back returns, as Excel's\nReapply would; a sort from the menu keeps the header row where it is.\nConditions run through the grid's own Excel-filter compiler\n(`@svgrid/grid/filtering`), so the operators are the ones the grid's\nfilter row has. The filter is per sheet, moves with an insert or delete,\nrides in `getState()` as `autoFilter` (`{ range, filters }`, a filter\nbeing `values`, `condition`, `date`, `color` or `top`) and reports\n`{ kind: 'filter' }` on `onChange`. Raised as `toggle-filter`.\n`hiddenRowsFor` takes a `fillAt` reader for the colour filters and a\n`today` for the date periods; `datePeriodBounds` is the calendar on its\nown.\n\n### Merged cells\n\nExcel's Merge & Center, on Home > Alignment as Excel's split button: the\nface merges the selection into one cell and centres it, the arrow opens\nMerge & Center, Merge Across (one merge per row), Merge Cells (no centring)\nand Unmerge Cells; the cell menu offers Merge & Center on a range and\nUnmerge Cells on a merge, and Merge & Center on a merged cell unmerges it.\nA merge keeps the top-left cell's value and drops the rest, so a merge\nover values asks first, with Excel's words; the clears, the centring and\nthe merge are one undo. The grid draws a merge as one cell (its\n`mergedCells`): it is selected as one, the arrow keys step over it, a\nclick anywhere on it lands on its top-left cell, which is what the formula\nbar shows and an edit writes, and the covered cells take no entry, fill or\npaste. A merge moves with an insert or delete (and grows when rows are\ninserted inside it), a one-row merge rides with its row through a sort,\nand a sort over a range a merge crosses is refused with Excel's sentence.\nMerges are per sheet, ride in `getState()` as `merges`\n(`[minRow, minCol, maxRow, maxCol]`) and report `{ kind: 'merges' }` on\n`onChange`. Raised as `merge-center`, `merge-across`, `merge-cells`\nand `unmerge-cells`.\n\n### Conditional formatting\n\nExcel's rules that colour cells by what they hold, on Home > Styles >\nConditional Formatting. Highlight Cells Rules (Greater Than, Less Than,\nBetween, Equal To, Text that Contains, Duplicate Values) and Top/Bottom\nRules (Top 10 Items, Bottom 10 Items, Above Average, Below Average) open\nthe small dialog: the value or values (a literal or a formula, so\n`=$B$1` follows B1) and the \"with\" style, Excel's Light Red Fill with\nDark Red Text and the rest. Data Bar, the Green - Yellow - Red and Green -\nWhite Color Scales and the Icon Set (3 Arrows) apply to the selection at\nonce; a data bar measures from zero, as Excel's do, so 10, 20 and 40 draw\na quarter, a half and the full width. Clear Rules from Selected Cells cuts the selection out of every rule\nit touches; Clear Rules from Entire Sheet empties the sheet's rules.\nManage Rules... is the Rules Manager: every rule in priority order with what\nit does, a sample and where it applies, Edit Rule (for the rules the\ndialog can edit), Delete Rule, Move Up, Move Down and Stop If True, for\nthis worksheet or the current selection; OK applies the lot as one undo.\n\nRules are evaluated over the COMPUTED values, so a Greater Than on a\ncolumn of formulas follows their results and a cell drops out when an\ninput changes. Priority is the Rules Manager's order (a new rule goes\nfirst, as in Excel): the first rule that decides a property (the fill,\nthe text colour, the bar, the icon) keeps it, and a rule with Stop If True\nstops the rules below it for the cells it matches. A rule's fill and text\ncolour paint over the cell's own format, as they do in Excel. The rules\nare per sheet, move with an insert or delete, ride in `getState()` as\n`conditionalFormats` and report `{ kind: 'conditional-formats' }` on\n`onChange`.\n\nNew Rule > Use a Formula... is Excel's \"format values where this formula\nis true\": the formula is written for the top-left cell of the selection\nand moves with each cell as a copied formula would, so `=$B2>100` on\nA2:A9 reads each row's B, and TRUE (or a number other than 0) formats.\nThe rule is `{ kind: 'formula', formula, style }`. A data bar over a range\nwith negatives puts its axis where zero falls between the minimum and the\nmaximum, and the bars grow away from it: right for a positive value, left\nand in red (or the rule's `negativeColor`) for a negative one. `evaluateCf`, `ruleStats`, `removeCf`, `shiftCf`,\n`describeCf` and the rule types are exported from\n`@svgrid/enterprise/sheet`; the grid's own value-driven\n[conditional formatting](./conditional-formatting.md) is a different thing\n(rules by column over raw values) and the shell does not use it.\n\nFreeze Panes lives on the View tab, Excel's dropdown: Freeze Panes (at the\nactive cell), Freeze Top Row, Freeze First Column, Unfreeze Panes\n(`freeze-panes`, `freeze-top-row`, `freeze-first-column`,\n`unfreeze-panes`). Beside it, View > Show has Excel's three toggles:\nGridlines, Formula Bar and Headings (the row numbers and column letters),\nraised as `toggle-gridlines`, `toggle-formula-bar` and\n`toggle-headings`. They are view settings of the component, not part of\nthe document, so `getState()` leaves them out.\n\n### Data validation\n\nExcel's rules over cells: Data > Data Validation opens on the rule at the\nactive cell and puts one over the selection. Allow is Any value, Whole\nnumber, Decimal, List, Date, Text length or Custom; the bounds (between,\nnot between, equal to, greater than and the rest) are literals or\nformulas, so `=$B$1` as a maximum follows B1 and a relative `=A2` on\nG2:G17 reads the same row's A, as in Excel; a List is a comma list or a\nrange or a name starting with `=`; a Custom formula is written for the\ntop-left cell of the selection and moves with each cell, TRUE (or a number\nother than 0) allowing the entry. A typed entry that breaks the rule never\nreaches the workbook: the alert box shows the rule's title and message (or\nExcel's own words) with Retry, which reopens the editor with the entry,\nand Cancel; a Warning rule asks \"Continue?\" and Yes lets the entry through.\nThe formula bar is checked the same way. Only typed entries are checked,\nas in Excel: paste, fill, Ctrl+D and the commands write what they are\ngiven.\n\nA List rule with In-cell dropdown draws an arrow on the active cell; the\narrow, or Alt+Down, drops the list (arrows and Enter pick, Escape leaves\nthe cell alone), and a pick is one undo. A typed entry is matched against\nthe list without regard to case.\n\nThe dialog's Input Message tab gives a rule a title and a message that\nshow in a small box under the cell while it is selected, as Excel's do;\nthe rule carries it as `input`. The arrow beside Data Validation has\nCircle Invalid Data, which draws Excel's red oval on every cell under a\nrule whose current contents break it (pasted, filled or written before the\nrule was), and Clear Validation Circles. The circles are worked out again\non every repaint while they are on, so a corrected cell loses its circle\nat once; switching sheets turns them off. `invalidCells` is the same check\nfor a shell of your own. Raised as `circle-invalid` and `clear-circles`.\n\nThe rules are per sheet, move with an insert or delete (and drop when\ntheir cells go), ride in `getState()` as `validation` and report\n`{ kind: 'validation' }` on `onChange`. `ruleAt`, `checkEntry`,\n`listChoices`, `removeValidation` and `shiftValidation` are exported\nfrom `@svgrid/enterprise/sheet` for a shell of your own, with\n`wb.evaluateText` and `wb.evaluateRange` evaluating a bound or a list\nsource in the sheet without storing it. Raised as `data-validation` and\n`open-list`.\n\n### Objects: charts and pictures\n\nInsert > Chart charts the selected block, or the region of data around\na single selected cell, as Excel starts one: the first row and column are\nread as the labels when they look like labels, each column of the block\nis a series, and the chart is anchored just under the block. It reads the\n**range**, not a copy of the numbers, so editing a cell redraws it.\nInsert > Picture puts an image on the sheet from a file, carried in the\ndocument as a data URL. A file the .xlsx cannot hold as it is (an SVG, say:\nExcel keeps only a few raster types in `xl/media`) is drawn to a PNG on\nthe way in, so what is on the sheet is what the file will carry; a picture\nwhose source is a web address is left out of the file, and Save As says\nso in the status bar.\n\n**`=IMAGE(source, [alt])`** is the other kind of picture, and the\ndifference is the point: an `IMAGE` cell IS the picture rather than\nfloating over one. It sorts with its row, filters with it, copies as a\nformula and moves when the cells move, with nothing to keep in step, which\nis what a catalogue with a thumbnail column wants. The source is a web\naddress or a `data:` URL; anything else stays text rather than becoming a\nbroken image. The second argument is the alt text, worked out like any\nother argument, so a screen reader is told what the picture is. The\nfunction's own value is the source, so a cell that reads it gets an\naddress rather than a picture it cannot use, and the file stores it as\n`_xlfn.IMAGE`, which is where Excel keeps it.\n\nAn object floats over the cells rather than living in them: drag it to\nmove, drag its corner to resize, press Delete to remove it, and\ndouble-click a chart (or Insert > Setup) to open the Chart dialog, where a\ntrendline (linear or a three-point moving average) goes over every series\nand one series can be moved to a secondary axis on the right, which is\nwhat makes a revenue-and-margin chart readable when the two are orders of\nmagnitude apart. Each\nchange is one undo. It hangs from a cell and an offset inside it, the way\nExcel's does, so inserting a row above moves it, deleting that row takes\nit with it, and resizing a column under it moves it without reshaping it.\n\nObjects are per sheet, ride in `getState()` as `objects` and report\n`{ kind: 'objects' }` on `onChange`. The chart's own drawing is the free\n`<SvChart>` from `@svgrid/grid`, so its types, palette and tooltips are\nthe grid's. Raised as `insert-chart`, `insert-picture`, `chart-setup`\nand `delete-object`, so an application can put its own chart builder in\ntheir place.\n\nObjects print with the sheet. File > Print hangs each one from its anchor\ncell with the offset and size it has on the page, and the default print\narea grows down and across to hold a chart anchored below the numbers,\nwhich is where a chart usually is. A named print area is honoured exactly\nas it stands, so an object outside it is left out.\n\nObjects ride in the .xlsx both ways, as Excel's own drawing part. A\npicture's bytes go into `xl/media` and a chart becomes a chart part of its\nown, both anchored to their cell; a file being opened gives its pictures\nand charts back the same way. A chart part carries the REFERENCES its\nseries read rather than a copy of the numbers, so Excel redraws it from\nthe cells beside it instead of from a snapshot that can drift. The one\nthing left behind is a picture whose source is a URL rather than a `data:`\nURL: its bytes are somewhere else, and a file with a broken image in it is\nworse than one without the image.\n\n### Hyperlinks\n\nTwo ways to put a link in a cell, both Excel's.\n\n**Insert > Link**, or Ctrl+K, puts a link ON the cell: the address, the\ntext to display, and a ScreenTip. The cell keeps whatever it says and the\nlink is kept beside it, so editing the text keeps the link, a format\nchange keeps it, and clearing the cell takes it away. Insert > Remove\ntakes the links off the selection. The cell menu has Excel's entries too:\nLink... on a plain cell; Edit Link..., Open Link and Remove Link on one\nthat carries a link. Each is one undo.\n\n**`=HYPERLINK(link, [friendly])`** puts one in a formula, showing the\nfriendly name. A cell holding that formula is clickable too, and its\ntarget is whatever the first argument works out to, so a link built from\nits neighbours (`\"…/issues/\" & A2`) goes where it says.\n\nA target that reads like an address moves the selection instead of leaving\nthe page: `Sheet2!B4`, `B4`, or a defined name, switching sheets when it\nhas to. Anything with a scheme, or a bare `www.`, opens in a new tab. A\nsingle click follows the link and a drag from the same cell selects, which\nis Excel's rule.\n\nOnly the schemes a link can sensibly mean are followed: `http`, `https`,\n`mailto`, `tel`, `sms`, `ftp` and `ftps`. A target carrying any other one,\n`javascript:` among them, is not a link at all: the cell keeps its text,\nnothing is clickable, the target is never written into a saved file, and one\nfound in a file being opened is dropped on the way in rather than carried\ninto the document. A\nlink is data, arriving from a colleague, a delta stream or an opened file,\nso what may be followed is named rather than assumed. Insert > Link refuses\nsuch an address in the dialog and names the ones a cell does open, and\nclicking a cell that already carries one says the same thing in the status\nbar rather than pretending the address is empty or somewhere on this\nworkbook.\n\nLinks are per sheet, ride in `getState()` as `links`, report\n`{ kind: 'links' }` on `onChange`, and move with an insert or a delete.\nThey go into the .xlsx as real hyperlinks, an external one as a\nrelationship with `TargetMode=\"External\"` and an internal one as a\n`location`, tooltips included, and a file opened back carries both.\nRaised as `insert-link` and `remove-link`.\n\n### Iterative calculation\n\nA circular reference is normally an error, and the shell says so: every\ncell in the loop shows `#CYCLE!` while the rest of the sheet keeps working.\nSome models are circular on purpose, because the answer is a fixed point: a\nbonus that is a tenth of the profit the bonus is taken out of, interest\ncharged on the balance the interest is part of.\n\n**Formulas > Calculation Options** is Excel's switch for those, raised from\nFile > Options because it is the one setting there that changes what a\nformula is worth. It takes the maximum number of passes over the loop, 100\nby default, and the smallest change worth another pass, 0.001 by default.\nWith it on, the loop runs from the values it last had until the numbers\nstop moving or the passes run out, and every cell in it holds a number\ninstead of an error.\n\nIt is workbook-wide rather than per sheet, so `wb.iteration` reads it and\n`wb.setIteration({ enabled: true })` sets it from code, recalculating as it\ngoes. It rides in `getState()` beside the defined names and the tables, and\nit goes into the .xlsx as `calcPr`, which is where Excel keeps it, so a\nfile saved with iteration on opens with it on. Raised as `calc-options`.\n\n### PivotTable from a range\n\nInsert > PivotTable summarises the selected block, or the region around\na single selected cell, on the same pivot engine the grid uses for its own\npivot mode. The dialog takes the source block\n(its first row the field names), where the result goes, and which field is\na row, a column or a measure, with Sum, Average, Count, Distinct count,\nMin and Max to summarise by.\n\nWhat the sheet keeps is the **definition**; what it writes is **cells**. A\npivot's result is an ordinary block, so it can be formatted, charted,\nfiltered, printed and saved to an .xlsx like anything else you typed.\nInsert > Refresh rebuilds it from the source and clears whatever the last\none wrote, which is what a pivot over live cells owes the user. With the\ncursor inside a written block, Insert > PivotTable opens the dialog on\nthat pivot instead of making another, so moving a field or changing an\naggregation is two clicks. Each write is one undo, cells and definition\ntogether.\n\nA field placed in **Filter** narrows the whole pivot to one of its values.\nThe filter is written above the block, a line per filter with the field\nand the value, so a reader sees what the numbers are of before reading\none; the dialog offers the values that field carries, and typing another\nvalue straight into the cell (or `(All)` to clear it) narrows the pivot at\nonce, since the cell IS the control. The filter applies before anything is\ngrouped, so the totals, the subtotals and Show Details all see the same\nrows.\n\n**Insert > Show Details** is Excel's drill-down: the source rows behind\nthe number in the cell the cursor is in, written to a sheet of their own,\nwith the field names across the top and the first row frozen. A cell in a\nsubtotal line opens its whole group, one in the grand total column opens\nthe whole line, and the grand total opens every row, which is what each of\nthose numbers stands for. The sheet is an ordinary one, so deleting it is\nhow it is dismissed. `pivotDrill` is the same answer as data.\n\nPivots are per sheet, ride in `getState()` as `pivots` and report\n`{ kind: 'pivots' }` on `onChange`. The definition moves with an insert or\na delete, and is dropped when its source or its target cell is deleted.\nRaised as `insert-pivot`, `refresh-pivot` and `pivot-details`.\n\nWith one measure the block is Excel's compact form: the column values\ncarry the corner label, the row labels run down the first column indented\nby level, and the grand total row and column can be turned off in the\ndialog. With two or more measures each column value gets one column per\nmeasure, under a header row naming them.\n\n### Sparklines\n\nExcel's smallest chart, and the one that is not an object: a sparkline IS\nthe cell. Insert > Sparklines offers Line, Column and Win/Loss, each\nopening the Create Sparklines dialog on the selected block with the data\nrange filled in and the location the column just past it. One sparkline\nis drawn per row of the data (per column, when the location is a row),\nread from the range rather than a copy of it, so editing a number redraws\nthe cell.\n\nThey are kept per GROUP, the way Excel keeps them: a data range, a\nlocation range of the same shape and the settings they share. So\n\n- selecting a cell that holds one turns the three kind buttons into a\n change to that group, Excel's Sparkline tab in one press;\n- Insert > Sparklines > Edit reopens the dialog on the group here, for\n its ranges, its colours, whether the whole group is drawn on one value\n scale and whether a line marks its last point;\n- Insert > Sparklines > Clear removes every group the selection touches.\n\nEach of those is one undo. A group moves with an insert or a delete, and\ngoes when the cells it is drawn in, or the cells it reads, are deleted.\nSparklines are per sheet, ride in `getState()` as `sparklines` and report\n`{ kind: 'sparklines' }` on `onChange`. The drawing is the free\n`<SvSparkline>` from `@svgrid/grid`. Raised as `sparkline-line`,\n`sparkline-column`, `sparkline-winloss`, `sparkline-setup` and\n`clear-sparklines`.\n\nA sparkline is behind whatever the cell shows, so a label typed over one\nstill reads, and the pointer goes through it: the cell is selected,\ndragged and edited as a cell. Sparklines ride in the .xlsx as Excel's own\nsparkline groups, both ways, in the worksheet's extension list where Excel\nkeeps them, and they are drawn on the printed page too, in the cells they\nbelong to.\n\n```svelte\n<script>\n const doc = createSheetDocument({ workbook: wb })\n doc.get('Traffic').sparklines = [{\n id: sparklineId(),\n data: [1, 1, 4, 12], // B2:M5, one row per channel\n location: [1, 13, 4, 13], // N2:N5, the cells they are drawn in\n type: 'line',\n markers: true,\n }]\n</script>\n```\n\n### Comments\n\nExcel's notes and its threaded comments: a text on a cell, marked by a red\ncorner and read by hovering the cell, and under it the replies, each with\nits author and time. Review > New Comment, Shift+F2 and the cell menu's\nNew Comment open the box beside the active cell; for a new comment it is\nthe note editor, and Ctrl+Enter or Save closes it, as does Escape or a\nclick elsewhere, keeping what was typed, as Excel keeps a note. On a cell\nthat has one, the box is the thread: the first entry with Edit and Delete,\nthe replies with theirs, a reply box (Ctrl+Enter posts) and Resolve in the\nhead, which greys the thread in the list and hides the reply box until\nReopen. Delete (on Review, in the box, in the cell menu) removes the whole\nthread; Previous and Next walk the sheet's comments row by row and wrap;\nShow All Comments opens a strip under the formula bar listing every\ncomment with its author, each a jump to its cell, which is the honest form\nover a sheet that only paints the rows in view. Every change is one undo.\n\n`commentAuthor` is who signs: with it set, a new comment and every reply\ncarry that name and the time, the way Excel signs a threaded comment with\nthe signed-in user; without it a new comment is a plain note and a reply\ncarries its time alone. Comments are per sheet, move with an insert or\ndelete, ride in `getState()` as `comments` (`r4` -> `B` -> a note's text,\nor `{ text, author, at, replies, resolved }` for a thread; every document\nsaved before threads existed reads as it did) and report\n`{ kind: 'comments' }` on `onChange`. Save As writes a note as Excel's\nlegacy note and a thread as its threaded comment with the persons part,\nand Open reads both. Raised as `new-comment`, `edit-comment`,\n`delete-comment`, `prev-comment`, `next-comment` and `toggle-comments`.\n\n### Protection\n\nExcel's model, without the password. Every cell is locked to begin with;\nFormat Cells > Protection (or Format > Lock Cell) unlocks the ones that may\nchange; Review > Protect Sheet turns the flags on. From then on a locked\ncell refuses to change wherever the change comes from: typing, F2, Delete,\nthe formula bar, paste, a fill, Ctrl+D, Find and Replace (which skips it),\na sort of the region, and the formats (the Font, Alignment and Number\nbuttons grey out while the selection holds a locked cell, and Ctrl+B\ndeclines). Insert and Delete grey out for the whole sheet, so do Row\nHeight, Column Width, AutoFit, Hide and Unhide, and the resize handles go\naway. The refusal is a sentence in the status bar, Excel's own: \"The cell\nyou're trying to change is on a protected sheet. To make a change,\nunprotect the sheet.\" Unlocked cells take every edit as before.\n\nWhat is drawn OVER the cells is covered too: a chart or a picture cannot be\nmoved, resized, retyped or deleted, and the sparklines of a protected sheet\ncannot be changed or cleared. The dialogs that would write them do not open\non a protected sheet, and the refusal sits on the write itself, so every way\nin (the ribbon, a dialog, the keyboard, a drag) says the same thing.\n\nProtect Sheet opens Excel's dialog with its \"allow all users of this\nworksheet to\" list: format cells, format columns, format rows, insert\ncolumns, insert rows, delete columns, delete rows, sort, use AutoFilter.\nWhat is ticked stays open once the sheet is protected: with Format cells\nthe Font, Alignment and Number buttons, Format Cells, merges and Lock\nCell work on every cell; with Format rows or columns Row Height, Column\nWidth, AutoFit, Hide and Unhide and the resize handles come back for that\naxis; the insert and delete entries open Home > Cells > Insert and Delete\nfor their axis; Sort allows a sort of a block that holds no locked cell,\nas Excel's does (a block with one is refused whatever the tick); Use\nAutoFilter allows the arrows on and off and their menus. The list is per\nsheet and kept, so Unprotect and Protect again open on the same ticks.\n\nReview > Allow Edit Ranges is Excel's Allow Users to Edit Ranges: a list\nof titled blocks (`B2:B10`, or several areas with commas) that take an\nedit on a protected sheet whether their cells are locked or not. New adds\none over the selection, Modify edits it in place, Delete removes it, and\nProtect Sheet... goes straight to that dialog. A range moves with an\ninsert or delete and goes with the rows it spans.\n\nProtect Sheet and Unprotect Sheet share one slot on the Review tab\n(`protect-sheet`, `unprotect-sheet`, and `allow-edit-ranges`); each is\none undo, so Ctrl+Z after Unprotect protects again, and after Protect\nputs the old allow list back. The flag is per sheet and rides in\n`getState()` as `protected`, the list and the ranges as\n`protection: { allow, ranges }` beside it (absent in an older document,\nwhich reads as nothing allowed and no ranges), the unlocked cells as\n`locked: false` in their format entries; `onChange` reports\n`{ kind: 'protection' }`. Save As writes the flag, the list and the\nranges as the file's `sheetProtection` and `protectedRanges`, and Open\nreads Excel's. A command written against the grid asks `cmd.canEdit(r, c)`\nbefore it writes and gets the same answer the editor does.\n\nThere is no password, on the sheet or on a range: the protection is\nagainst mistakes, not against the person at the keyboard, who can unprotect\nwith one click.\n\nData > Sort A to Z and Z to A sort the current region (or the selection) by\nthe active cell's column: numbers before text, blanks last, a header row of\ntext over numbers left in place, values and formats moving together, one\nundo.\n\nEvery one of them raises its `onAction` first. A handler that returns\n`true` has taken the action over and the shell's dialog stays closed, so an\napplication can put its own Format Cells in place of this one:\n\n```svelte\n<SvSheet workbook={wb} onAction={(action, cmd) => {\n if (action === 'format-cells') { openMyDialog(cmd); return true }\n}} />\n```\n\n## Grouping rows and columns\n\nData > Group folds a block of rows or columns away behind a button, the way\nExcel's outline does. Group and Ungroup act on the selection, grouping an\nalready grouped run nests it, and the numbered buttons at the corner of the\nbar show the whole sheet at one depth. Auto Outline groups the runs between\nthe totals the sheet already has; Clear Outline removes the lot.\n\nThe summary line is the one the button sits on, and it is the line AFTER\nthe detail on both axes: the total row under its rows, the total column to\nthe right of its columns. That is Excel's `summaryBelow` and `summaryRight`,\nand both round-trip through the .xlsx as `outlineLevel` and `collapsed`\nper line.\n\nA line a collapsed group folds is not a line you hid. The two are kept\napart in the document on purpose: if folding recorded a hidden row, the row\nwould stay invisible after its group reopened and there would be no button\nleft to bring it back. Unhide leaves a folded row to its group, the way it\nalready leaves a filtered row to its filter.\n\n## Cells drawn as a control\n\nInsert > Controls draws the selected cells as a checkbox or a button\ninstead of as text, and `SvSheet`'s model has a radio group beside them.\n\nA control is a rendering and never a second source of truth. A checkbox is\nticked because its cell reads `TRUE`, not because the checkbox remembers\nbeing clicked, so everything else keeps working over it:\n`=COUNTIF(A1:A9, TRUE)` counts the ticks, undo undoes one, a paste sets\none, and the file carries an ordinary boolean. Excel's own cell checkbox\nworks this way too, which is why the default pair is `TRUE` and `FALSE`.\nA sheet that wants `Yes` and `No` says so on the region.\n\nA button reports its press through `onCellAction`:\n\n```svelte\n<SvSheet {data}\n onCellAction={(event) => {\n // `event.action` is the region's own action name, so one column of\n // buttons can be told from another.\n if (event.action === 'approve') approve(event.row)\n }} />\n```\n\nClear Control puts the cells back to ordinary ones and leaves their values\nwhere they are.\n\n## A tab that is a table, not cells\n\nA workbook tab can be a bound table rather than a cell grid. Name it in\n`gridSheets` with its fields and its records and it renders as a `<SvGrid>`,\nwith its own headers, sorting, filtering and inline editing, in the same\nworkbook as the cell sheets beside it.\n\n```svelte\n<SvSheet\n data={[{ name: 'Orders', cells: [] }, { name: 'Summary', cells: [] }]}\n gridSheets={{\n Orders: {\n fields: [\n { field: 'item', label: 'Item' },\n { field: 'qty', label: 'Qty', type: 'number' },\n { field: 'price', label: 'Price', type: 'number' },\n ],\n rows: orders,\n editable: true,\n },\n }} />\n```\n\nThe records are the truth, and after every change they are projected into\nthe workbook's cells, header row included. That is what makes a bound tab\nmore than an embedded widget: on the Summary tab,\n\n```\n=SUM(Orders!B2:B99)\n=VLOOKUP(\"Gadget\", Orders!A2:C99, 2, FALSE)\n```\n\nboth work, because the formula engine, the dependency graph, the file\nwriters and the printer are never told that the Orders tab is different.\nThe projection goes through the same write an ordinary edit uses, which\nignores a write that changes nothing, so editing one field of one record\nrewrites one cell and recalculates only what read it.\n\nWhat a bound tab deliberately does not do is hold formulas of its own. Its\ncells are a rendering of its records, so anything typed into them would be\noverwritten by the next projection. A sheet that needs formulas beside the\ndata is a cell sheet reading the bound one across, which is what the\nexample above is.\n\n## Localisation\n\nEvery string the shell shows is English by default and lives in one flat\nmap, `SheetMessages`, the way the grid's `GridMessages` does. Pass any\nsubset as `localization.text` and unset keys stay English; `locale` is the\nBCP-47 tag the status bar's Sum, Average and Count are printed with, and\nit reaches the grid underneath for its own matching and formatting.\n\n```svelte\n<SvSheet {data}\n localization={{\n locale: 'de-DE',\n text: {\n 'ribbon.tab.home': 'Start',\n 'ribbon.bold.title': 'Fett',\n statusReady: 'Bereit',\n statusRecordsFound: '{shown} von {total} Datensätzen gefunden',\n 'formatCells.title': 'Zellen formatieren',\n ok: 'OK', cancel: 'Abbrechen',\n },\n }} />\n```\n\nThree families of key:\n\n- **The ribbon's** are read off the ribbon model, so every tab, group and\n button is covered without a second list: `ribbon.tab.<tab>`,\n `ribbon.group.<group>`, `ribbon.<item>.label`, `ribbon.<item>.title`\n (the tooltip), `ribbon.<item>.option.<value>` for a dropdown's entries\n and `ribbon.<item>.none` for its \"no fill\" kind of entry. The ids are\n the `RibbonActionId`s: `ribbon.bold.label`, `ribbon.file-open.title`,\n `ribbon.number-format.option.percent`.\n- **The dialogs'** are `<dialog>.<part>`: `formatCells.title`,\n `filter.dateFilters`, `validation.allow.list`, `findReplace.findNext`,\n `sort.addLevel`, `goalSeek.setCell`, and so on.\n- **The chrome's** are plain camelCase: `statusReady`, `nameBox`,\n `newSheet`, `menuFormatCells`, `ok`, `cancel`, the sentences the status\n bar says (`openedFile`, `sheetProtected`, `noInvalidData`).\n\nA sentence with `{placeholders}` (`statusRecordsFound`, `deleteSheetMessage`,\n`linesToggled`, `textToColumns.status`) lets a translator choose the word\norder; `formatMessage` fills them. `defaultSheetMessages` is exported, so\nthe full English map is there to copy from, and `resolveSheetMessages`\ngives the merged map for an app that wants to read it.\n\nWhat stays English: the function names and error values (`SUM`,\n`#VALUE!`), which Excel keeps in every locale too, the rule descriptions\nthe Conditional Formatting Rules Manager lists, and the font names.\n\n### How numbers and formulas are spelled\n\n`locale` also decides how a number and a formula are WRITTEN. Under\n`de-DE` a cell shows `1.234,5`, the formula bar shows `=ROUND(A1/3; 2)`,\nand `1,5` typed into a cell is the number rather than text. The marks come\nfrom `Intl`, so a locale nobody planned for still comes out right, and\nExcel's own rule decides the separator: wherever the decimal mark is a\ncomma the argument separator is a semicolon, since the comma is busy.\n\nThe document is unaffected, and that is the point. A formula is stored as\n`=SUM(1.5, A1)` whatever the locale, so a file written in Berlin opens in\nBoston and `getState()` means the same thing on every machine. The culture\nis put on when a formula is shown for editing and taken off when one is\ntyped. A `,` typed where the separator is `;` is taken as a separator too,\nbecause a comma with no digit after it cannot be a decimal mark, so an\nEnglish keyboard habit is not punished.\n\nA number FORMAT string stays invariant as well: `#,##0.00` is the pattern\nin every locale, and it is the rendering of that pattern that follows the\nculture.\n\nOverride it where the locale is not the whole story:\n\n```svelte\n<!-- German strings, but the dot for decimals this team is used to. -->\n<SvSheet {data} localization={{ locale: 'de-DE', culture: { decimal: '.' } }} />\n\n<!-- German strings, invariant spelling: for a team sharing files with\n English-speaking colleagues. -->\n<SvSheet {data} localization={{ locale: 'de-DE', culture: false }} />\n```\n\n## Two people on one sheet\n\n`createDeltaStream` turns the document's changes into small messages, and\n`applySheetDelta` applies one that arrived from somewhere else. Put a\nsocket between the two and the sheet is collaborative.\n\n```svelte\n<script>\n import { createSheetDocument, createDeltaStream } from '@svgrid/enterprise'\n\n const doc = createSheetDocument({ sheets })\n const socket = new WebSocket(url)\n const stream = createDeltaStream(doc, {\n onDelta: (delta) => socket.send(JSON.stringify(delta)),\n })\n socket.onmessage = (event) => stream.apply(JSON.parse(event.data))\n // A new participant asks for everything once:\n socket.onopen = () => stream.resync()\n</script>\n\n<SvSheet document={doc} height=\"100%\" />\n```\n\nFive kinds of delta go out, and each is as small as it can be:\n\n- **`cells`** carries the RAW TEXT of the cells that were written, not\n their values. A formula travels as `=SUM(B2:B4)` and the other side works\n out its own answer, so the two cannot disagree about a number and the\n receiving sheet keeps a formula in the cell.\n- **`structure`** carries the insert or delete itself, so both sides\n rewrite their own formulas identically.\n- **`state`** carries the one part of the one sheet that changed, the way\n `getState` serializes it: the formats, the merges, a rule, the objects.\n- **`document`** carries the whole state, for the changes not worth\n describing piecemeal or that cannot be: adding or removing a sheet, a\n restore, and the parts the WORKBOOK keeps rather than a sheet, which are\n its tables, its defined names and its calculation settings. A `state`\n delta is keyed by sheet and carries a sheet's own entry, so none of those\n fit in one: without this the two sides drift silently, and the same\n `=SUM(Orders[Amount])` answers a number on one and `#REF!` on the other.\n `resync()` sends the same kind.\n\nThe shell follows its document, so a delta applied from outside repaints\nthe sheet without anything else being called. `refresh()` remains for a\nwrite made straight to the workbook, which the document never hears about.\n\n**Conflicts are last writer wins, per cell.** A delta carries no version\nand no transform: two people typing in the same cell end on whichever\nmessage arrived last, and both converge on it. Two people typing in\ndifferent cells never conflict, which is the case that actually happens.\nAnything stronger, operational transform or a CRDT, is a different piece of\nwork and is not pretended at here. Applying is idempotent for `cells` and\n`state` and is not for `structure`, so a transport that can deliver twice\nhas to deduplicate.\n\n### Presence: where everyone is\n\nThe deltas carry what people TYPE. Presence carries where they ARE, which\nis what stops two people typing into the same cell.\n\n`presence` is a prop, a list of who else is on this workbook, and the shell\ndraws each person on the active sheet as a thin coloured box around their\nselection with their name on their cursor. `onPresence` fires whenever this\nuser's own selection moves, with the sheet, the rectangle and the active\ncell, which is what an application broadcasts.\n\n```svelte\n<SvSheet\n document={doc}\n presence={others}\n onPresence={(me) => stream.sendPresence({ id: myId, name: myName, ...me })}\n/>\n```\n\nThe fifth delta kind, `presence`, rides the same wire so an application\nneeds only one: `sendPresence` sends it, and it arrives at the receiving\nstream's `onPresence` rather than at the document. That is deliberate.\n**Presence is never part of the document**: not in `getState()`, not in the\n.xlsx, and `applySheetDelta` ignores it. A cursor belongs to a session, and\na file that remembered where someone's cursor was last week would be\nremembering nothing worth keeping.\n\nA person with no `colour` is given a stable one from their id, so a set of\npeers is never all one colour, and anyone who has not been heard from for\nfifteen seconds is dropped, since a closed tab says no goodbye. A cursor\nmoves on every arrow key, so throttle `onPresence` if the transport\ncharges by the message; the shell does not, because it cannot know what\nthe transport costs.\n\n## How big a sheet it holds\n\nMeasured rather than guessed, by `pnpm bench` over the built engine (the\ncases are in `tools/bench/sheet-cases.mjs`, the numbers and the rig in\n[Benchmarks](../benchmarks.md#spreadsheet-engine)). On a CI-class Linux\ncontainer:\n\n| Sheet | Opens in | Retains |\n| ---------------------------------------- | -------- | ------- |\n| 1,000 rows, a formula each | 8 ms | 1.6 MB |\n| 10,000 rows, a formula each | 91 ms | 16.5 MB |\n| 50,000 rows, a formula each | 547 ms | 84.2 MB |\n\nA keystroke costs one evaluation rather than a sheet's worth: the\ndependency graph recomputes what read the cell, and nothing else. What\nmakes an edit expensive is not the size of the sheet but how much of it\none cell feeds. A single `SUM` over 10,000 cells is one evaluation that\nre-reads ten thousand cells (14 ms); a running-balance column, where every\nrow reads the row above, is 10,000 evaluations for one keystroke, and that\nis the true cost of that shape.\n\nCI gates the evaluation counts rather than the milliseconds, since a count\nis the same on every machine.\n\n## Right to left, touch and assistive tech\n\nThe shell carries no direction of its own: set `dir=\"rtl\"` on the page or\non any element above it and everything mirrors, the row gutter and the\nfrozen columns to the right edge, the ribbon and the tab strip in reading\norder, an anchored chart hanging from its cell's right edge and growing\nleftwards. The arrow keys follow the reading order with it, so on a\nright-to-left ribbon ArrowLeft is the next tab.\n\nThe two bands that cannot fit a phone pan with a finger rather than\nclipping: the ribbon scrolls sideways over its groups and over its tab\nlabels, and the cells pan in both axes. The formula bar keeps a long entry\non one line, cut off at the right and scrolling with the caret, until the\nchevron expands it; a one-row box that wrapped would only hide the rest. A tap picks a cell, a second tap on the same cell opens its\neditor, and the formula bar is where the address and the formula are read\nand typed.\n\nFor assistive tech the shell is the bands it looks like: the ribbon is a\ntoolbar whose tab strip is a `tablist` with one Tab stop, the arrows plus\nHome and End moving between tabs with focus following the selection; the\nsheet tabs are a second `tablist` with the same model; the formula bar is\na labelled group whose Name Box reads the active cell as its value; the\ncells are the grid's own `role=\"grid\"`; every dialog takes focus and\ngives it back to the cells on Escape. `tests/e2e/sheet-a11y.spec.ts`,\n`sheet-rtl.spec.ts` and `mobile/sheet-touch.spec.ts` hold the shell to\nall of this in a real browser, the first with an axe audit of every band.\n\nOne thing the shell does not decide: a font colour or a fill the DOCUMENT\nsets is used as it stands, on a dark theme as on a light one. A red on a\ndark canvas is as hard to read here as it is in Excel, and the answer is\nthe same one: change the document's colours, not the app's.\n\n## What it does not do\n\nThe shell is the part of Excel a team's spreadsheet uses every day; these\nare the parts it leaves out, on purpose, so nothing on the ribbon is a\nbutton that does nothing.\n\n- **Tabs:** no track-changes on Review; Page Layout has no themes, no\n page breaks and no header or footer text, and Print has no preview of\n its own beyond the browser's.\n- **Objects** are charts and pictures: a chart is what the Chart dialog\n sets and no more, so there are no per-series colours, no data labels and\n no axis titles. A floating picture whose source is a URL rather than a\n `data:` URL is left out of the .xlsx, since its bytes are not in the\n document to write; a picture in a cell, `=IMAGE(...)`, has no such limit,\n because the formula is what the file carries.\n- **Sparklines** are the three Excel draws: no axis options beyond one\n scale for the group, and no high and low point marks beyond the last one.\n They ride in the .xlsx as Excel's sparkline groups, both ways, and they\n print with the sheet.\n- **A PivotTable** is a definition plus the cells it writes, not a live\n object: no drag-and-drop field list and no slicers, and a report filter\n holds one value at a time rather than a tick list. Refresh brings it up\n to date and Show Details is the drill-down.\n- **Collaboration** is the delta stream and the presence overlay above and\n nothing more: no server, no operational transform, no follow-the-leader\n scrolling, and last writer wins per cell.\n- **The outline** groups, ungroups, collapses and shows a level, and it\n round-trips. What it does not have is Excel's Data > Subtotal, the\n command that inserts `SUBTOTAL` formulas at each break AND groups what\n it just broke up; the grouping half is here, the formula-writing half is\n not. `SUBTOTAL` itself works, so a sheet can write its own.\n- **A cell drawn as a control** carries its VALUE into a file and not its\n control: a checkbox column saves as the `TRUE` and `FALSE` it means, and\n reopens as ordinary cells. The controls live in `getState()`, so an app\n that saves the document keeps them; an app that saves only .xlsx keeps\n the data and loses the decoration.\n- **A bound tab** holds no formulas of its own, since its cells are made\n from its records and would be overwritten. It is not written to the\n .xlsx as an Excel table either: its projection goes out as ordinary\n cells, which is what Excel would show anyway.\n- **The culture** changes how a number and a formula are spelled, not what\n they are called: function names stay English, as they do in the file\n format itself.\n- **Protection** takes no password, on the sheet or on an edit range: it\n guards against mistakes, not against the person at the keyboard.\n- **Validation** checks what is typed; pasted-over cells are left as they\n land until Circle Invalid Data is asked for.\n- **Conditional formatting** has Excel's presets and Excel's \"with\"\n styles, one icon set per flavour, and no colour pickers of its own for\n bars and scales; the grid's own value-driven rules are a separate\n feature.\n- **AutoFilter**'s values list is the whole column, unvirtualised, which\n is what a sheet's region holds.\n- **Files** carry what each format has a place for, so the three are not\n equals: the .xlsx carries the most, the .ods has no charts, pictures,\n validation, conditional formatting or frozen panes, and the .xls\n is the oldest and carries the least, with no charts, pictures,\n sparklines, validation, conditional formatting or comments, a\n sheet of 65,536 rows by 256 columns, and a formula using a function Excel\n 97 never had saved as the value it worked out.\n\n## More examples\n\n### A tab that is a table, and rows that fold away\n\nA workbook tab that holds RECORDS rather than cells: the Orders tab renders as the data grid, with its own headers, sorting, filtering and inline editing, beside ordinary cell sheets. Its records are projected into the workbook's cells, header row included, so the Summary tab reads it with plain formulas: SUM, SUMIF, SUMPRODUCT and VLOOKUP all reach across, because the formula engine is never told the tab is different. Summary also shows Data > Group, with three regional blocks folded under their subtotals and the numbered level buttons at the corner of the outline bar. Edit a Qty on Orders and every figure follows.\n\n<div data-docs-demo=\"498-sheet-bound-tabs\" data-height=\"560\"></div>\n\n### Engineering, statistics, and the locale you type in\n\nFour function families the engine gained at once, worked through a real example of each: a bearing from ATAN2 and DEGREES, a register mask in binary and hex with BITAND, an orchard queried through Excel's criteria-block grammar with DSUM, DCOUNT, DAVERAGE and DGET, and NORM.DIST with its inverse, a confidence interval and the chi-squared and t tails. The picker switches how numbers and formulas are SPELLED: German shows 1.234,5 and =ROUND(A1/3; 2) while the document still stores 1234.5 and a comma, so the file opens anywhere. Column H is drawn as checkboxes whose ticks an ordinary COUNTIF counts.\n\n<div data-docs-demo=\"499-sheet-engineering-stats\" data-height=\"560\"></div>\n\n\n### Review workflow: comments + Protect Sheet\n\nAn expense sheet under review on the Excel shell. It opens protected: every cell is locked except the reviewer's Receipt and Status columns, so a keystroke on an amount is refused and the status bar says why. Three reviewer notes sit on the cells they are about (Excel's red corner): hover, Shift+F2, Review > Next, Show All Comments. Status is a list, the totals by status are SUMIFs, and protection, notes and rules all ride in the document.\n\n<div data-docs-demo=\"460-review-comments-protection\" data-height=\"560\"></div>\n\n### Order intake: data validation\n\nAn order form with Excel's Data Validation on it: Region and Product are lists (the arrow or Alt+Down drops them; Product reads the price list on a second sheet), Qty is a whole number from 1 to 500 with a Stop alert and Retry, Discount is a decimal up to 20% with a Warning you can keep, Ship by is a date on or after the order date through a relative bound (=A2 moves with the row). Unit price is an XLOOKUP, the total follows.\n\n<div data-docs-demo=\"461-order-intake-validation\" data-height=\"560\"></div>\n\n### Regional scorecard: conditional formatting\n\nEight regions, three months, a target, and every colour on the page is a conditional formatting rule over computed values: Highlight Cells on attainment (under 90% red, 100% and over green), a Data Bar and a Top 3 on the quarter, a Green - Yellow - Red Color Scale across the months, an Icon Set on the trend. Edit a month and the arrow, the bar, the fill and the rank all move; Manage Rules lists the six in priority order.\n\n<div data-docs-demo=\"462-regional-scorecard-cf\" data-height=\"560\"></div>\n\n### Budget report: merged headers\n\nA half-year budget report laid out with merged cells the way Excel lays one out: a title merged across the page, Q1 / Q2 / H1 group headers merged over their months, a Line corner merged down two rows, a notes paragraph merged into one wrapped block. A merge is one cell to the grid: click inside Q1 and the Name Box says B2, Merge & Center lights, the arrows step over it and its column letters are shaded. Unmerge and merge again from the ribbon; Ctrl+Z each step.\n\n<div data-docs-demo=\"463-merged-report-headers\" data-height=\"560\"></div>\n\n### Support ticket log: AutoFilter\n\nForty support tickets with Excel's Filter on the header row, opened already filtered to what is still open: the funnel on Status, blue row numbers, \"N of 40 records found\" in the status bar. The arrows drop Excel's menu: sort, Clear Filter, Text and Number Filters with two conditions, a search box, (Select All) and the values with counts. The rows are worked out again after every edit, so a ticket typed Closed folds away at once. Ctrl+Shift+L toggles it.\n\n<div data-docs-demo=\"464-ticket-log-autofilter\" data-height=\"560\"></div>\n\n### Autosave: a document that survives a reload\n\nA project tracker that keeps itself in localStorage: onChange fires once per tick with every kind of change, the demo debounces it and writes getState() as JSON, and the next visit rebuilds the document with createSheetDocument({ state }) and hands it to <SvSheet document>. Add a comment, bold a row, hide a column, type a task, reload: it is all back, rules and merged title and frozen rows included. Reset puts the shipped document back with setState().\n\n<div data-docs-demo=\"465-autosave-document\" data-height=\"560\"></div>\n\n### Paste from Excel: formats and formulas survive\n\nExcel puts an HTML document on the clipboard beside the tab-separated text: formats in a style block keyed by class, formulas in x:fmla, raw numbers in x:num. The sheet reads it, so a pasted block arrives bold, filled, with its number formats, and with its formulas moved to where they landed; Google Sheets' data-sheets-formula flavour reads the same. Two buttons put exactly what Excel and Sheets put on the clipboard; click a cell and Ctrl+V.\n\n<div data-docs-demo=\"466-paste-from-excel\" data-height=\"560\"></div>\n\n### Loan model: PMT, accounting formats and the File tab\n\nA mortgage model built from the sheet's own financial functions: PMT for the payment, IPMT and PPMT for each period's split, NPER and RATE for the term and the rate that fit, SUMPRODUCT for the first year of interest. The money cells wear the Accounting format, Extra Payment carries a validation rule with an Input Message, and Circle Invalid Data rings what breaks it. The File tab is the point: Save As writes the whole model as an .xlsx Excel opens with its formulas, formats and rules, Open reads one back, Export CSV takes the active sheet.\n\n<div data-docs-demo=\"484-loan-model-files\" data-height=\"560\"></div>\n\n### Charts and pictures on the sheet\n\nCharts anchored over the cells the way Excel anchors one: each reads a range rather than a copy of the numbers, so typing into a cell redraws it. Insert > Chart charts the selected block and reads its first row and column as the labels, Insert > Picture puts an image on the sheet, and a double-click opens the Chart dialog for the type, the title, series in columns or rows and stacking. Drag an object to move it, its corner to resize, Delete to remove. It hangs from a cell, so inserting a row above moves it, and it rides in getState().\n\n<div data-docs-demo=\"485-sheet-charts-objects\" data-height=\"560\"></div>\n\n### Sparklines: a chart inside the cell\n\nExcel's smallest chart, and not an object: a sparkline IS the cell. One per row of a block of numbers, drawn from the range rather than a copy, so editing a number redraws it. Insert > Sparklines offers Line, Column and Win/Loss, with Edit for the group's ranges, kind and colours and Clear for the groups the selection touches; selecting a cell that holds one turns the kind buttons into a change to that group. They are kept per group the way Excel keeps them, ride in getState() and move with an insert or a delete.\n\n<div data-docs-demo=\"486-sheet-sparklines\" data-height=\"560\"></div>\n\n### PivotTable from a range\n\nExcel's Insert > PivotTable over a block of cells, on the same pivot engine the grid uses for its own pivot mode. The sheet keeps the definition, the source block, where the result goes and which field is a row, a column or a measure; the result is plain cells written in one undo, so it can be formatted, charted, printed and saved to an .xlsx like any other block. Refresh rebuilds it from the source, opening the dialog from inside one edits it, and Show Details writes the source rows behind a cell to a sheet of their own.\n\n<div data-docs-demo=\"487-sheet-pivot-range\" data-height=\"560\"></div>\n\n### Two people on one sheet\n\nTwo full spreadsheets over two separate documents, wired to each other by createDeltaStream: type in either and the other follows, and each window shows the other's cursor as a coloured box with a name on it. What crosses the wire is a delta rather than the document, and the log shows each one as it goes: a formula travels as its text so the other side works out its own answer, an insert travels as the edit so both rewrite their own formulas, a format travels as the one part of the one sheet that changed. Conflicts are last writer wins, per cell.\n\n<div data-docs-demo=\"488-sheet-collaboration\" data-height=\"620\"></div>\n\n### Evaluate Formula and Error Checking\n\nThe two auditing tools that answer why a cell says what it says, over a commission model with three planted faults. Evaluate Formula underlines one part of the formula and replaces it with its value on each click. Error Checking walks every cell that reports an error, with a sentence on what each one means, and the one row whose formula is not the column's formula. Show Calculation Steps takes one straight into the other.\n\n<div data-docs-demo=\"493-sheet-auditing\" data-height=\"560\"></div>\n\n### Iterative calculation\n\nA circular reference is normally an error, and every cell in the loop shows #CYCLE!. Two models here are circular on purpose: a bonus that is a share of the profit it is taken out of, and interest charged on the balance it is part of. Formulas > Calculation Options turns iteration on with its two limits, and both settle on their fixed point; turn it off and the cycle is an error again. The setting rides in getState() and goes into the .xlsx as calcPr.\n\n<div data-docs-demo=\"492-sheet-iterative\" data-height=\"560\"></div>\n\n### IMAGE: a picture inside the cell\n\nExcel's IMAGE puts a picture IN a cell rather than floating one over it, so it sorts with its row, filters with it, copies as a formula and moves when the cells move. A product list whose thumbnail column reads the swatch beside it: sort by price and the pictures follow their rows. The source is a web address or a data URL, anything else stays text rather than becoming a broken image, and the second argument is the alt text a screen reader reads.\n\n<div data-docs-demo=\"494-sheet-cell-images\" data-height=\"520\"></div>\n\n### Hyperlinks: Insert > Link and HYPERLINK\n\nExcel's two ways of putting a link in a cell. Insert > Link (Ctrl+K) puts one on the cell, so editing the text keeps it and clearing the cell takes it away; the HYPERLINK function puts one in a formula with a friendly name. A target that reads like an address moves the selection instead of leaving the page, so a cell links to another sheet; anything with a scheme opens in a new tab. A single click follows, a drag selects. Links ride in getState(), move with an insert, and go into the .xlsx both ways.\n\n<div data-docs-demo=\"489-sheet-hyperlinks\" data-height=\"560\"></div>\n\n## See also\n\n- [`<sv-sheet>`](../web-components/sv-sheet.md): the shell as a custom\n element for React, Vue, Angular or a plain page, with React and Vue\n wrappers.\n\n- [Excel keyboard shortcuts](./keyboard-shortcuts.md)\n- [Formulas](../spreadsheet-formulas.md)\n- [Number formats](./number-formats.md)\n- [Workbooks](./workbooks.md)\n"
4153
+ "markdown": "# The spreadsheet shell\n\nEverything between the ribbon and the sheet tabs, as one component.\n\n```svelte\n<script>\n import { SvSheet, createWorkbook } from '@svgrid/enterprise'\n\n const wb = createWorkbook([\n { name: 'Budget', cells: [['Line', 'Jan'], ['Rent', '2400']] },\n ])\n</script>\n\n<SvSheet workbook={wb} />\n```\n\n<div data-docs-demo=\"27-spreadsheet-ribbon\" data-height=\"640\"></div>\n\nThat is the whole required API. `<SvSheet />` with no props at all opens an\nempty single-sheet workbook, which is what \"open a spreadsheet\" means.\n\nThis page is the reference: every prop, every dialog, the file formats\nand the limits. The tutorials walk through the same ground a task at a\ntime, with a live sheet under each step: [Getting started](../sheet/start.md),\n[Formulas](../sheet/formulas.md), [Formatting](../sheet/formatting.md),\n[Data validation](../sheet/validation.md), [Files](../sheet/files.md),\n[Data tools](../sheet/data-tools.md),\n[Charts, sparklines and pictures](../sheet/charts-and-objects.md) and\n[Review and share](../sheet/review.md).\n\n## What you get\n\n| Part | Comes from |\n| --- | --- |\n| Ribbon: File, Home, Insert, Formulas, Data, Review, View | `SvSheetRibbon` |\n| Name Box and fx bar, showing the RAW text of the active cell | `SvFormulaBar` |\n| A..Z headers over the built-in 1..N row gutter | `SvGrid` |\n| Sheet tabs: switch, rename, reorder, add, duplicate, hide and unhide | `SvSheetTabs` |\n| Sum / Average / Count of the selection | the shell |\n| Every Excel shortcut | `enableSheet()` |\n\nThis is composition, not a second grid. All of it already existed and was\nbeing re-assembled by hand in four separate demos. What the component adds is\nthe wiring BETWEEN the parts, done once:\n\n- The formula bar shows the raw text while the grid shows the computed value.\n Editing in the bar edits the formula, not its result.\n- The format store is keyed by row id, so sorting cannot strand formatting on\n whatever row took that index.\n- Insert and delete go through the workbook, so references are rewritten on\n every sheet rather than only the active one.\n- The fill handle translates references, so `=B2` filled down becomes `=B3`.\n- The tab strip is told when a shortcut moved the active sheet, so\n `Ctrl+PageUp` and the tabs cannot disagree.\n- AutoSum measures its run against evaluated values, so a column of subtotals\n counts as numbers rather than as `\"=SUM(...)\"` strings. With one cell\n selected it proposes the `=SUM(...)` in the cell's editor, as Excel's\n does, so a guess that stopped a row short is corrected before Enter takes\n it; a wider selection has the sum written outright.\n- Ctrl+Z and Ctrl+Y, and the ribbon's Undo and Redo, go to the sheet the\n change was made on: a step made on Orders and undone from Summary brings\n Orders up and changes the cell back there, as Excel does, rather than\n landing on Summary's cell of the same address. Every step the grid\n records is marked with its sheet for this (`api.setHistoryTag` and\n `api.peekUndo` on the grid), the mark follows a rename, and Delete\n Sheet empties the undo list, as Excel's does. A click that selects a\n chart or a picture is not a step; a drag that moves one is.\n- A click on a sheet tab, on the strip's add button or on an entry of the\n tab menu leaves the keyboard on the cells, so the arrow keys, Ctrl+Z and\n Shift+F11 work on the sheet that just came up. The arrow keys inside the\n strip keep the focus on the tabs, as a tablist's do.\n- Formats are kept per sheet, as they are in Excel. Bold on Summary!C5 says\n nothing about Orders!C5, and the store follows a sheet through a rename.\n- The Name Box lists the workbook's defined names. Pick one and the shell\n selects what it refers to, switching sheets first when it lives elsewhere;\n type an address and press Enter to go there; type a name that is not one\n yet and it is defined for the selection, sheet-qualified and absolute as\n the Name Manager writes one, one undo. Either way focus comes back\n to the sheet, so the next keystroke edits the cell just reached. Beside it\n sit Excel's three: Cancel and Enter while a formula is being typed, and\n fx, which raises `insert-function`.\n- A formula-bar entry lands in the cell the edit started in, however the\n edit ends: Enter, or a click on another cell. It is one step in the\n grid's history, so Ctrl+Z takes it back.\n- Enter after an in-cell edit commits and moves down, Shift+Enter up, Tab\n right, as in Excel. Alt+Enter is a line break: the cell turns on Wrap\n Text and its row grows to show every line, as a wrapped row does after\n Wrap Text on the ribbon or in Format Cells; a row the fitting grew is\n back to its height once the wrap goes or the text gets shorter, while a\n row sized by hand, dragged or typed into Row Height..., stays as the\n user left it, which is Excel's auto-height rule. F4 while editing turns the\n reference at the caret through `$A$1`, `A$1`, `$A1` and back.\n- AutoComplete, as Excel's: while a cell is typed into, the text entries in\n the same column that run without a blank above and below it are matched\n against what was typed, and when exactly one fits, the rest of it appears\n selected in the cell. Typing on overwrites it, Backspace takes it away,\n and Enter or Tab accepts it in the entry's own case, so \"jo\" under \"John\n Smith\" commits \"John Smith\". Numbers, formulas and booleans are never\n offered, and two entries that both fit (\"Apple\", \"Apricot\" for \"Ap\")\n offer nothing until the typing tells them apart. `completeEntry(typed,\n entries)` is the rule on its own.\n- Formulas are typed the way Excel's are. Pointing: a click on a cell while\n a formula is being typed, in the cell or in the bar, puts that cell's\n address in at the caret rather than ending the edit, a drag puts a range,\n and a second click replaces what the last one put there; typing goes on\n from there. Formula AutoComplete: the function names a typed prefix could\n be are listed under the cell, Up and Down walk them, Tab or Enter takes\n one with the caret inside its brackets, Escape closes the list. Backspace\n on a cell opens the editor empty, so Escape gives the value back where\n Delete would have blanked it.\n- Data entry steps the way Excel's does: the Enter that ends a run of Tabs\n goes down from the column the run began in, and inside a selected block\n Enter and Tab walk the block and wrap at its edges while the block stays\n selected. Ctrl+Enter puts the entry into every cell of the block at once,\n each taking its value and format as if typed there, as one undo. A drag, Shift+click or Shift+Arrow grows the selection from\n its far corner and leaves the active cell at the anchor, white inside the\n tinted range, and the formula bar shows that cell.\n- The formula bar follows an in-cell edit as it is typed, and the cell\n follows the bar; both go back when the edit is cancelled.\n- The formula bar keeps a cell's line breaks. It shows one line until the\n chevron at its end expands it to every line the text takes at the bar's\n width, whether from a line break or from wrapping, up to six, with a\n scrollbar past that; Alt+Enter typed in the bar breaks the line there and\n expands the bar.\n- A typed `12%` is the number 0.12 shown as a percentage, `$1,200` is 1200\n shown as currency, `1,234.5` keeps its separator: the entry names a value\n and a format, and the cell takes both unless it already has a number\n format of the same kind (a time typed into a currency cell makes it a\n time cell, as it does in Excel; `5%` typed into a `0.0%` cell keeps the\n cell's decimals). `=A1*2` over a `12%` cell is 0.24. A plain number\n typed into a cell that already shows percentages is that percentage, so\n `5` in a `0%` cell is 5% rather than 500%, as Excel's automatic percent\n entry has it; `(5)` is -5, the way a statement writes a negative. A mixed\n fraction with a whole part, `3 1/2`, is 3.5 shown back as a fraction, and\n `0 1/2` is how you enter a bare half without `1/2` becoming the second of\n January; `=A1*2` over `3 1/2` is 7.\n- A date typed the American way, `3/4/2026`, `3/4/26` or `3/4`, or with\n the month spelled out, `4-Mar-2026`, `4 Mar 26`, `March 4, 2026` or\n `Mar 4` (English month names, in either order), is the sheet's\n `2026-03-04` under a date format, and a clock time, `10:30`,\n `6:00:00` or `10:30 PM`, is the fraction of a day it is under a time\n format, so the times add up. In arithmetic a date written as text is its\n day number, as a date cell is in Excel: `=A1+1` under a date is the next\n day rather than `#VALUE!`, and `=B1-A1` over two dates is the days\n between them.\n- A formula typed into a cell with no format takes the number format of\n the first cell it reads, as Excel's does: `=A1*2` under `$5.00` is\n `$10.00`, `=SUM(B2:B9)` over currency is currency, `=A1+1` under a date\n is a date and `=TODAY()+7` is a day. A formula whose function returns a\n count or a part (COUNT, LEN, YEAR and the rest) stays General, and so\n does one date taken from another, which is a number of days, the one\n exception Excel makes too.\n- File > Open reads what the other spreadsheets write, not only what this\n one wrote: a sheet's own `<cols>` or a table's filter, a row height\n LibreOffice left unflagged, `General` as the absence of a format, a row\n or a cell that carries no position of its own, an error or a boolean\n saved as a formula. Save As writes a package Excel, Google Sheets and\n LibreOffice all open; a rule it cannot spell is left out rather than\n written badly, since a file with one bad element in it is a file Excel\n offers to repair.\n- A sort reorders the rows you can see and leaves the hidden ones where\n they are, as Excel's does, so sorting a filtered list cannot drag a\n filtered-out row into view or overwrite what one holds. The status bar\n says how many stayed put. A row takes its formulas along the way a copy\n would: the relative references shift to the row's new place and the\n absolute ones stay, so `=SUM(B5:E5)` on a row that lands in row 2 reads\n `B2:E2`, and an `=IMAGE(C5)` thumbnail still shows its own row's picture.\n- `SUBTOTAL` follows the filter: `=SUBTOTAL(9, C2:C99)` under a filtered\n list totals the rows that matched, and the 101-111 codes leave out rows\n hidden by hand too. That is what a table's totals row should be written\n with; `SUM` counts everything, as it does in Excel.\n- The status bar's Average, Count and Sum are of the cells you can see: a\n filtered-out row is not in the total, which is what makes the bar worth\n reading over a filtered block. Delete and Clear Contents leave such a row\n as it is, so clearing a filtered selection cannot wipe what the filter\n hid.\n- A copy leaves out the rows a filter folded away, and the block closes up\n around them, so filtering a log and copying the block gives the rows that\n matched with nothing between them. A row hidden by hand with Hide Rows\n IS copied, as Excel copies one (the grid's `includeCollapsedRows` prop is\n how the shell tells the two apart); a hidden column is left out. The\n merges inside the block travel with a paste of everything or of the\n formats, laid over the landing from its corner, and a merged cell copied\n on its own is the whole merge, as it is in Excel.\n- Ctrl+X marks the block rather than emptying it, as Excel's cut does: the\n cells stay where they are until the paste lands, Escape leaves the sheet\n as it was, and one Ctrl+Z puts a whole move back. The ribbon's Cut and\n the menu's mean the same thing.\n- Cut and paste MOVES the cells, so every formula that read them follows:\n moving A1 to D1 rewrites `=A1*2` as `=D1*2`, across every sheet and the\n defined names, the way Excel does. A `$` makes no difference, since the\n cell itself moved; a range follows only when all of it moved, so\n `=SUM(A1:A2)` stays as it is when A1 alone goes. A copy translates\n relative references by the distance pasted, as before.\n- A typed error code is that error, as it is in Excel: a cell holding\n `#N/A` answers TRUE to `=ISNA(A1)` and carries the error into anything\n that reads it, so a placeholder row reads as missing rather than as the\n text that spells it. Case does not matter, and `'#N/A` is the text.\n- A leading apostrophe says the rest is text, as it does in Excel: `'007`\n is the text `007`, leading zero kept, and `'=A1+1` shows the formula\n rather than working it out. The apostrophe is not part of the value, so\n it is never displayed, printed, exported or compared; the formula bar\n shows it, since it is what was typed. A cell that comes back from an\n .xlsx as text keeps its prefix, so a part number survives the round trip.\n- The fill handle fills the way Excel's does: a lone value repeats, `1, 2`\n continues, `Jan` runs on to `Feb`, and a formula moves every relative\n reference by the distance dragged. Ctrl held at the release turns it the\n other way, a series into a copy and one number into a series. Double-click\n it to fill down as far as the column beside the selection has data. A number too wide for its\n column reads as `####` until the column fits it; `TRUE` and `FALSE`\n typed into a cell are booleans, centred.\n- Freeze Panes freezes everything above and left of the active cell, both\n halves, per sheet: the rows stay under the column letters and the columns\n beside the row numbers while the rest scrolls, and every frozen cell is\n still an ordinary cell. Unfreeze clears both.\n- A dragged column width or row height is one Ctrl+Z, and double-clicking\n a row number's lower edge fits the row to its wrapped text, as a\n double-click on a column letter's edge fits the column to its text.\n- Row heights are the sheet's: a row dragged taller stays that way through\n every edit, moves with its row when rows are inserted or deleted above it,\n comes back with an undo, and belongs to one sheet. The fill handle's drag\n is one undo, like Ctrl+D. Column widths move with their columns the same\n way.\n- Hide and Unhide, on the column letter and row number menus and on Ctrl+9\n (rows), Ctrl+0 (columns), Ctrl+Shift+9 and Ctrl+Shift+0: a hidden line\n keeps its letter or number and every reference to it, and takes no room,\n so the headers read A, B, D. Unhide reveals what the selection spans, or\n the hidden lines right next to it when it spans none. The cursor steps\n over hidden lines, Ctrl+Arrow lands on the last cell of a run that shows\n rather than its hidden end, and the Name Box can still reach a hidden\n cell; a hide is one Ctrl+Z, moves with an insert or delete, and belongs\n to its sheet.\n- Text spills over empty neighbours and is clipped by the first cell that\n holds something, so a title in A1 reads in full and a label beside a\n number does not run under it. Numbers never spill. The text runs only\n over neighbours with the same background as its own cell: a title on a\n banded row reads across the band, and a filled cell beside an unfilled\n one (or the other way round) keeps its text in its cell, since the run\n would drag one colour over the other.\n- General alignment: numbers right, text left, errors and booleans centred,\n unless the cell says otherwise.\n- The headers of every column and row the selection touches are shaded and\n marked with a line in the accent, the Select All corner sits top-left,\n and the status bar reads Ready on the left and Average / Count / Sum on\n the right.\n- Sheet tabs delete, insert, rename, move, duplicate, hide and unhide from\n a right-click menu, as in Excel; nothing on the tab face can remove a\n sheet, and deleting one that holds data asks first. A hidden sheet keeps\n its cells and every reference to it, Ctrl+PageUp and PageDown step over\n it, and the flag rides in the document. Duplicate copies the cells and\n everything the document keeps beside them, and the copy's table, charts,\n pictures, sparklines and PivotTables are its own: each gets a fresh name\n or identity, so working on the copy leaves the sheet it came from alone.\n- Click a column letter to select the column, a row number to select the\n row, the corner to select the sheet; Shift+click extends. Drag the edge of\n a row number to resize the row, as with a column letter.\n- Copy puts two things on the system clipboard: the text each cell shows,\n which is what a text editor expects, and an HTML table with the formats,\n the numbers behind the formatted text and the formulas, written the way\n Excel writes its own (`mso-number-format`, `x:num`), so a paste into\n Excel or Google Sheets keeps the number format, the bold and the fill. A\n paste back into the sheet gets the formula, moved by the distance it\n travelled (`=A1*2` copied from B1 to B3 reads `=A3*2`), and the cell's\n format, as Excel pastes. A cut block is moved, not translated. The block\n Ctrl+C or Ctrl+X took is outlined by Excel's marching ants until Escape,\n a typed entry, a structural edit, the paste of a cut, or the next copy; a\n Ctrl+V paste of a copy keeps them, so the block can be pasted again, and\n Enter pastes it once and drops them, as Excel's Enter does. The Copy and\n Cut buttons on the ribbon and in the cell menu are the same commands as\n the keys.\n- Paste reads what the clipboard carries. A block copied from Excel arrives\n with its values (the number, not the text `1,234.50`), its number\n formats, fonts, colours and fills, and its formulas: Excel's HTML says\n nothing about where a block was copied from, so a formula whose\n references all fall inside the block is kept and moved with the paste as\n if the block came from A1 (a totals column keeps totalling), and one that\n reaches outside the block lands as its value. A block from Google Sheets\n the same, with its R1C1 formulas turned into A1 for the cells they land\n in. Plain text lands as typed. One copied cell fills the\n selected range, and a block that fits the selection a whole number of\n times is repeated over it, as in Excel. One paste is one Ctrl+Z, formats\n included.\n- Selecting works as it does in Excel. A click on a column letter or a\n row number selects the whole line and puts the active cell on its first\n visible row or column; a drag along the letters or the numbers selects a\n run; Shift+click extends the run from the active cell's line; Ctrl+click\n adds a line beside the selection; the corner selects everything and\n leaves the active cell where it was. Shift+Arrow grows a run of whole\n lines, an arrow collapses to the active cell, and typing lands in the\n active cell, so the keyboard never leaves the sheet. Shift+Space and\n Ctrl+Space take the whole rows or columns the selection touches, Ctrl+A\n the current region then the sheet, Ctrl+Shift+8 the region alone. A\n column or row selected whole paints its letter or number in the accent,\n white on green as Excel's; a line the selection merely touches is\n tinted. While a range is dragged out the Name Box reads \"3R x 2C\".\n- The pointer reads as it does in Excel: the white cross over the cells,\n a black arrow down a column letter or along a row number, the\n double-headed arrows on the borders, the crosshair on the fill handle.\n Dragging a border shows Excel's tip beside the pointer, \"Width: 8.43 (64\n pixels)\" or \"Height: 15.00 (20 pixels)\", with a dotted guide across the\n sheet at the new edge; dragging the fill handle shows what the cell\n under the pointer will get (Feb, 7, 2026-09-18), in the source cell's\n number format.\n- A General cell shrinks a number to its column before it hashes, as\n Excel's does: the decimals go first (5.333333 reads 5.3333, then 5.33),\n then a whole part too wide turns scientific (1.76E+04). A formatted\n number hashes as soon as its format does not fit, since the format said\n what to show.\n- Right-click a column letter or a row number for its menu: the column or\n row is selected first, unless it was already inside the selection, and the\n menu offers Insert, Delete and Column Width... or Row Height... for that\n axis alone, as Excel's does.\n- Right-click a cell for Excel's menu: Cut, Copy, Paste, Paste Special,\n Insert / Delete Rows and Columns, Clear Contents, Clear Formats, Merge &\n Center or Unmerge, the comment entries, Format Cells, each with the\n ribbon's icon for it so the two read as one set. Structure goes through\n the sheet's own insert and delete, which rewrite every formula the\n change reaches, move the formats with their cells, and undo as one step.\n- The in-cell editor opens with the caret after the text and widens over\n the cells to its right as a long formula is typed. While a formula is\n being written, in the cell or in the formula bar, every range it refers\n to is outlined in its own colour, one rectangle per range, and the\n reference in the text takes the same colour, in the cell and in the bar:\n Excel's range finder. Colours go by distinct range in order of first\n appearance, so `B5:B8` twice is one colour and `b5`, `$B$5` and `B5` are\n one range. `referenceSpans(text)` is the rule, exported.\n\n## The ribbon is Excel's ribbon\n\nThe layout is Excel's, down to the geometry: a group is three rows of 24px,\na **large** button (icon over label) fills a column of that height and\n**small** buttons stack three to a column, so Cut / Copy / Format Painter\nline up beside a tall Paste, as icons alone the way Excel draws them. The\nHome tab runs Clipboard, Font, Alignment, Number, Cells, Styles, Editing in\nExcel's order, with Excel's names on the buttons (Undo and Redo open the\nEditing group as a column of small icons), a dialog-box launcher in the corner of the groups that have a\ndialog, and the group label underneath, which is what makes a ribbon read\nas a ribbon rather than a toolbar.\n\nSome things worth knowing are in there because Excel users reach for them\nwithout looking:\n\n- **Paste is Excel's split button.** The icon pastes; \"Paste\" with its\n chevron under it opens the kinds: Paste, Formulas, Values, Formatting,\n Transpose, and Paste Special... at the end, each raised as an action\n (`paste-formulas`, `paste-values`, `paste-formats`, `paste-transpose`,\n `paste-special`) that the shell answers through its Paste Special engine.\n- **Format Painter is the brush.** Select the cells whose look you want,\n press the brush, and the next click or drag takes it, as one undo: a\n single cell takes the whole block from that corner, a dragged range has\n the block tiled over it, and a cell whose source had no format loses its\n own, as in Excel. The button reads pressed while the brush is up, the\n pointer is a brush over the cells, Enter paints onto the current\n selection, and Escape or the button again puts it down. Raised as\n `format-painter`.\n- **Fill Colour, Font Colour and Borders are split buttons.** The face\n re-applies the last pick (yellow and red to start, as in Excel); the arrow\n opens the menu. The colour menus are Excel's picker: the theme row, five\n tints and shades under each colour, then the standard colours. Borders\n applies to the *selection* the way Excel does: Bottom Border lines the\n bottom edge of the block, All Borders lines every cell, Outside frames it.\n- **The combos show the cell.** Font, size and number format read the\n active cell, so a 17px title says 17 even though 17 is not on the list.\n- **Every format change is one Ctrl+Z**, through the grid's own history,\n so the ribbon's Undo lights up after Bold and undoing it puts the cell's\n whole entry back. A command that writes cells and formats together is\n still one press.\n- **Paste pastes.** It reads the system clipboard the way Ctrl+V does, so\n it needs a secure context; elsewhere it stays quiet, as Ctrl+V does.\n\n**It is responsive the way Excel's is, in Excel's two steps.** When the\nband is narrower than its groups, the groups go compact from the right\nfirst: their small buttons keep the icon and drop the label, so Fill Down\nbecomes an arrow and Wrap Text a glyph, with the title still in the\ntooltip. Only when every group is compact and the band is still too\nnarrow do groups fold, again from the right, into one large button each\n(the group's own icon over its name and a chevron) that opens the group in\na dropdown. Nothing is hidden, only folded; widen the window and it all\ncomes back in the same order. Every width is measured off an invisible\ncopy of the band, never estimated, so a group folds exactly when it would\nnot fit. On a phone the folded band scrolls sideways.\n\n**It collapses the way Excel's does.** Double-click a tab, press the\nchevron at the band's end, or Ctrl+F1, and the band goes away leaving the\ntab row. A click on a tab then shows that tab's band over the sheet until\na command runs, Escape, or a click elsewhere; the pin at its end, a\ndouble-click or Ctrl+F1 brings the band back for good. The state is the\n`collapsed` prop of `SvSheetRibbon`, bindable, and the action is raised\nas `toggle-ribbon` so `onAction` can watch or take it over.\n\n## The ribbon and the keyboard are the same thing\n\nClick a bold cell and the Bold button lights up. Press `Ctrl+B` and it goes\nout. Neither is reacting to the other: both call one function in\n`sheet/shortcuts.ts`, and the button's pressed state is read from the format\nstore rather than tracked separately.\n\nThat is why `sheet/ribbon.ts` is a table of data rather than markup. An item\neither carries a `run` that calls a real action, or an `emits` naming\nsomething the ribbon cannot do alone:\n\n```ts\n{ id: 'bold', label: 'B', title: 'Bold', keys: 'Ctrl+B',\n kind: 'toggle',\n run: (cmd) => toggleFormat(cmd, 'bold'),\n isOn: (cmd) => everyCellHas(cmd, (e) => e?.bold === true) }\n```\n\nButtons that open a dialog set `emits` and raise an action. Every action\nreaches your `onAction` handler first; return `true` to take it over,\notherwise the shell's own dialog opens:\n\n```svelte\n<SvSheet\n {wb}\n onAction={(action, cmd) => {\n if (action === 'goal-seek') { openMyGoalSeekDialog(cmd); return true }\n }}\n/>\n```\n\nThe shell answers everything on the ribbon itself: `recalculate`,\n`toggle-formulas` (Excel's `Ctrl+` `` ` ``), `toggle-filter`, `sort-asc` /\n`sort-desc` / `sort-custom`, and the dialogs listed below. One button has\nnothing behind it in the library, Insert > Table, and is left off the\nribbon unless `extras={['insert-table']}` says the application answers it.\n\n## Props\n\n| Prop | Default | What it does |\n| --- | --- | --- |\n| `document` | one built from `workbook` or `data` | The sheet document: the workbook plus every sheet's formats, sizes, hidden lines, frozen panes, comments, protection, validation and conditional formatting. Build it with `createSheetDocument` to save, restore or listen to it outside the component. |\n| `workbook` | a new empty one | The workbook to edit, when not supplying a document. |\n| `data` | | Seed sheets, when not supplying a workbook. |\n| `rows` / `columns` | 50 / 12 | Minimum size, so a sparse sheet still looks like a sheet. |\n| `height` | 420 | Grid viewport height. |\n| `columnWidth` | 96 | Default column width. |\n| `rowHeight` | 22 | Row height. Excel's is 20 at 100%; 22 fits the 13px cell font. |\n| `look` | `'theme'` | `theme` paints the shell with the host's `--sg-*` tokens, so it follows the app's theme; `excel` pins Excel's own palette and geometry, light or dark with the page. |\n| `columnWidths` | | Per-column overrides keyed by letter: `{ A: 150 }`. |\n| `formats` | | Cell formats keyed by address: `{ B2: { bold: true } }` for the sheet active at mount, `{ 'Orders!F2': ... }` for another sheet. |\n| `showRibbon` / `showFormulaBar` / `showTabs` / `showStatusBar` | `true` | Hide any part of the chrome. |\n| `onAction` | | Every ribbon action, first; return `true` to take one over. |\n| `extras` | `[]` | Whether to show Insert > Table, because the application answers it. |\n| `commentAuthor` | | Who signs a new comment and a reply, with the time; unset, a new comment is a plain note. |\n| `localization` | | `{ locale, text }`: the language of the chrome. `text` overrides any of the shell's strings (see [Localisation](#localisation)); `locale` formats the status bar's numbers and reaches the grid underneath. |\n| `onReady` | | The `SvGridApi` and the document, once the grid has mounted. |\n| `onChange` | | Every change the user lands, once per tick: `cells`, `formats`, `sizes`, `hidden`, `freeze`, `sheets`, `comments`, `protection`, `page-setup`, `objects`, `validation`, `conditional-formats`, `structure` (with the insert or delete), `restore`. Undo and redo report too. |\n\n`formats` matters more than it looks: without it a sheet can only be\nformatted by hand after it loads, which makes it impossible to ship a\ndocument that opens the way it was saved.\n\nA bare address formats the sheet that is active when the shell mounts.\nQualify it to reach another sheet, the way a formula would - `Orders!F2`,\nor `'Price list'!C2` when the name has a space:\n\n```svelte\n<SvSheet\n workbook={wb}\n columnWidths={{ A: 150 }}\n formats={{\n A1: { bold: true, fill: '#e2e8f0', color: '#0f172a' },\n E2: { numFmt: '#,##0' },\n B13: { numFmt: '0.0%' },\n 'Orders!F2': { numFmt: '$#,##0.00' },\n }}\n/>\n```\n\n<div data-docs-demo=\"456-sales-report-workbook\" data-height=\"600\"></div>\n\n## Files\n\nThe File tab is the part of Excel's a document in a page can do: New,\nOpen, Save As, Save As ODS, Save As XLS, Export CSV and Print. Open takes an\n.xlsx, an .ods, an .xls or a .csv from disk - the kind is read from the\nbytes, not from the name - and replaces\nthe document with everything the file holds that the document keeps\n(cells with their formulas, formats, widths and heights, hidden lines and\nsheets, frozen panes, merges, the filter with its criteria and the rows it\nfolds, validation, conditional\nformatting, protection, comments, names, the active sheet); Save As\ndownloads the document as an .xlsx that Excel, Google Sheets and LibreOffice\nopen with the same parts; Save As ODS writes the same document in\nOpenDocument, the format LibreOffice Calc saves by default and one of the\nfiles Google Sheets hands back, with the cells, their formulas in ODF's own\nreference grammar, the number formats, the looks, widths and heights, merges,\nhidden lines, links and notes; Save As XLS writes an Excel 97-2003 workbook,\nthe binary .xls that every Excel since 1997 opens and that a bank or an ERP\nexport still often is; Export CSV downloads the active sheet as its cells\nshow, and Open reads such a file back: the separator is guessed, so a comma,\na semicolon or a tab file all read as themselves, a quoted field keeps its\ncommas and line breaks, and a field that reads as a percentage, a currency\namount or a grouped number lands as the number with the format it implies.\nA CSV field is never read as a formula, as it is not in Excel;\nNew starts over with one empty sheet, asking first when the sheets hold\nanything; Print (Ctrl+P) opens the active sheet in the browser's print\ndialog as its Page Layout says (below). `documentToXlsx` and\n`documentFromXlsx` are the two halves, in `@svgrid/enterprise/sheet`, and\nneed the `jszip` peer; `documentToOds` and `documentFromOds` are the same\npair for OpenDocument; `documentToXls` and `documentFromXls` are the pair for\nthe old binary format, and need nothing, since an .xls is not a zip; and\n`documentFromFile` takes any of the four and decides by the bytes. `csvText`\nand `sheetStateFromCsv` are the CSV pair, and need nothing either.\n\nWhat an .ods carries is the document this package keeps, not every corner of\neither format: cells and their formulas (translated both ways, so\n`=SUM(A1:A3)` here is `of:=SUM([.A1:.A3])` there, and a structured reference\nbecomes the rectangle it names, since ODF has none), number formats, the cell\nlooks, column widths and row heights, merges, hidden rows and columns, the\nfilter with its values and conditions (a date period or a colour has no ODF\nspelling, so its rows go out hidden instead), hyperlinks, notes, defined\nnames and sheet protection. Charts,\nimages, sparklines, validation, conditional formatting and frozen\npanes travel in the .xlsx and not yet in the .ods. A PivotTable travels\nin every format as the cells it wrote, never as a definition: the .xlsx\nhas no pivot part, so Excel sees the block, not a pivot to refresh.\n\nThe .xls is the oldest of the three and carries the least. Cells and their\nformulas travel, with the formulas as the RPN tokens Excel 97 stored rather\nthan as text: a function that format never had (XLOOKUP, LET, TEXTJOIN, a\nstructured reference) has no token, so such a cell goes out as the value it\nworked out, and a workbook opened from an .xls reads those the same way\nround - Excel writes `_xlfn.XLOOKUP` as a defined name and calls it, and\nthat name turns back into the function here. Number formats, fonts, colours,\nfills, borders, alignment, column widths, row heights, hidden rows, columns\nand sheets, merges, frozen panes, sheet protection and defined names travel;\ncharts, pictures, sparklines, validation, conditional formatting and\ncomments do not, since the format kept them somewhere this reader does not\ngo. A filter goes out as the rows it folds, hidden, and nothing else: the\nfile shows the same rows, and the arrows do not come back from it. The sheet is 65,536 rows by 256 columns, and anything past that edge is\nleft out rather than written wrong.\n\nAn app that keeps its workbooks somewhere other than the user's disk takes\nthe actions over through `onAction` (`file-open`, `file-save-xlsx`,\n`file-save-ods`, `file-save-xls`, `file-new`, `file-export-csv`,\n`file-print`) and calls the component's\nown methods: `open(file)` replaces the document with an .xlsx, .ods, .xls or\n.csv Blob, `toXlsx()` returns the document as a Blob, `toOds()` the same\ndocument in OpenDocument, `toXls()` it as an Excel 97-2003 workbook,\n`toCsv()` the active sheet as text, `newWorkbook()` empties it, `print()` opens the print dialog and\n`printHtml()` returns the page it would print, for an app that prints\nits own way.\n\n```svelte\n<SvSheet bind:this={sheet} onAction={(action) => {\n if (action === 'file-save-xlsx') { sheet.toXlsx().then((blob) => upload(blob)); return true }\n if (action === 'file-open') { pickFromServer().then((blob) => sheet.open(blob)); return true }\n}} />\n```\n\nDates are `yyyy-mm-dd` text in the sheet and serial numbers in the file:\na text date goes out as a serial under a date format, and a serial under a\ndate format comes back as text. Tables, charts and images are not carried\neither way.\n\n## Page Layout and Print\n\nThe Page Layout tab is Excel's Page Setup group and Sheet Options, per\nsheet and part of the document: Margins (Normal, Narrow, Wide),\nOrientation, Size (Letter, Legal, Tabloid, A3, A4, A5), Print Area (Set\ntakes the selection, Clear puts the whole sheet back), Print Titles (the\nrows that repeat at the top of every page), the group's launcher opening\nthe Page Setup dialog with all of it and the scale, and Print Gridlines\nand Print Headings. Each change is one undo and reports\n`{ kind: 'page-setup' }`.\n\nFile > Print (Ctrl+P) lays the sheet out as one HTML document and hands it\nto the browser's own print engine, so fonts, CJK and RTL come out right\nand nothing is bundled: the print area or the used range, column widths\nand row heights as the sheet shows them, hidden lines and the rows a filter\nfolds away left out, merges as\nspans, every cell as it shows with its format and its conditional style,\nthe title rows repeated on each page, the sparklines drawn in their cells\nand the charts and pictures hung from theirs, a table's header and banding\nin the colours it wears on the sheet, gridlines and headings when\nasked, and the orientation, paper and margins in `@page`. \"Save as PDF\" in that\ndialog is the PDF. The setup rides in `getState()` as `pageSetup` and in\nthe xlsx as `pageSetup`, `pageMargins`, `printOptions` and Excel's own\n`Print_Area` and `Print_Titles` names, both ways.\n\n## Saving and restoring\n\nEverything the user does lands in the sheet's document: the workbook (raw\ntext, formulas as typed, names, the active sheet), and per sheet the\nformats (locked flags included), column widths, row heights, hidden lines,\nfrozen panes, comments, the protection flag with its allow list and edit\nranges, the page setup, the charts and pictures on the sheet, the\nvalidation rules and the conditional formatting rules.\n`getState()` returns it as plain JSON and\n`setState()` puts it back into the same workbook object, so an autosave\nis an `onChange` handler and a reload is one call after mount:\n\n```svelte\n<script lang=\"ts\">\n import { SvSheet, type SheetState } from '@svgrid/enterprise'\n\n let sheet = $state<SvSheet>()\n let timer: ReturnType<typeof setTimeout> | undefined\n\n function autosave() {\n clearTimeout(timer)\n timer = setTimeout(() => {\n if (sheet) localStorage.setItem('budget', JSON.stringify(sheet.getState()))\n }, 400)\n }\n\n const saved = localStorage.getItem('budget')\n</script>\n\n<SvSheet\n bind:this={sheet}\n onChange={autosave}\n onReady={() => { if (saved) sheet?.setState(JSON.parse(saved) as SheetState) }}\n/>\n```\n\n### Autosave to a server\n\nThe same handler with a `fetch` in it, plus the three things a network\nmakes you think about:\n\n```ts\nlet timer: ReturnType<typeof setTimeout> | undefined\nlet saving: Promise<unknown> = Promise.resolve()\nlet revision = 0\n\nfunction autosave() {\n clearTimeout(timer)\n // Coalesce a burst of edits into one request. onChange already coalesces\n // per tick; this coalesces per pause in the typing.\n timer = setTimeout(() => {\n const state = sheet!.getState()\n // One request at a time, in order: a save that overtakes its\n // predecessor writes an older document over a newer one.\n saving = saving.then(async () => {\n const response = await fetch(`/api/books/${id}`, {\n method: 'PUT',\n headers: { 'content-type': 'application/json', 'if-match': String(revision) },\n body: JSON.stringify(state),\n })\n if (response.status === 412) return onSomeoneElseSaved()\n revision = Number(response.headers.get('etag') ?? revision + 1)\n })\n }, 800)\n}\n```\n\n- **Debounce, then serialise.** `getState()` is a full snapshot, so the\n cost is in the transfer, not the call. One request per pause, and never\n two in flight, or a slow request lands after a fast one and the older\n document wins.\n- **Carry a revision.** Send what you last saw and let the server refuse a\n save built on a stale document, rather than discovering the overwrite\n later. `412` is the moment to reload, or to ask.\n- **Send changes instead, when there are two people.** A whole state per\n keystroke is fine for one user and wrong for two. `createDeltaStream`\n (see \"Two people on one sheet\") gives a delta per change, small enough to\n send as it happens, and applies one from someone else without a reload.\n\nA save that runs on a timer rather than on `onChange` is worse, not\nbetter: it saves when nothing changed and misses the tab closing. If the\ntab closing matters, save on `visibilitychange` as well, with the same\nsnapshot.\n\n`onChange` is called once per tick with every reason since the last call,\nso a paste of forty cells is one call, and it says what changed\n(`reasons.some((r) => r.kind === 'structure')` for an insert or delete, with\nthe edit on the reason). A change to the workbook's own parts rather than a\nsheet's, its names or its calculation settings, reports `workbook`; a table\nreports `tables`. A host writing those straight to the workbook raises the\nsame reason with `document.changed({ kind: 'workbook' })`, which is what\nputs it on a delta stream. `setState` clears the grid's undo history, since\nnone of it describes the restored sheet, and reports a single `restore`.\n\nThe same document is available outside the component: build it with\n`createSheetDocument({ sheets })` or `createSheetDocument({ state })`, pass\nit as `document`, and call `document.getState()`, `document.setState()` and\n`document.subscribe()` on it directly. A document built this way can be\nsaved and restored in Node, with no component at all.\n\nThe shape, `SheetState` (exported with `SheetStateEntry`, `ValidationRule`\nand `CfRule`):\n\n```ts\n{\n version: 1,\n workbook: {\n sheets: [{ name, cells }], active, names,\n tables: [{ name: 'Orders', sheet: 'Budget', headerRow: 0, firstCol: 0, lastCol: 4, lastRow: 12, hasTotals: false }],\n iteration: { enabled: true, maxIterations: 100, maxChange: 0.001 },\n },\n sheets: {\n Budget: {\n formats: { 'r0 A': { bold: true, numFmt: '#,##0', locked: false } },\n columnWidths: { A: 150 },\n rowHeights: [[4, 44]],\n hidden: { rows: [7], cols: [] },\n freeze: { rows: 1, cols: 0 },\n comments: { r1: { B: 'Check with finance' }, r4: { D: { text: 'Over budget?', author: 'Ana', at: '2026-03-04T10:00:00.000Z', replies: [{ text: 'By 3%', author: 'Ben', at: '2026-03-04T11:00:00.000Z' }] } } },\n protected: false,\n protection: { allow: { formatRows: true }, ranges: [{ id, title: 'Inputs', rects: [[1, 1, 9, 1]] }] },\n pageSetup: { orientation: 'landscape', paper: 'A4', margins: { top: 0.75, bottom: 0.75, left: 0.7, right: 0.7, header: 0.3, footer: 0.3 }, printArea: null, printTitleRows: [0, 0], gridlines: false, headings: false, scale: 100 },\n objects: [{ id, kind: 'chart', anchor: { row: 12, col: 1, dx: 8, dy: 8, width: 420, height: 260 }, range: [0, 0, 11, 2], type: 'bar', headers: true, series: 'columns' }],\n sparklines: [{ id, location: [1, 6, 9, 6], data: [1, 1, 9, 5], type: 'line', color: '#2563eb' }],\n pivots: [{ id, source: [0, 0, 200, 3], target: { row: 0, col: 8 }, rows: ['Region'], cols: [], values: [{ field: 'Amount', agg: 'sum' }] }],\n links: { r1: { B: { target: 'https://example.com', tip: 'The spec' } } },\n merges: [],\n validation: [{ id, rects, allow: 'whole', operator: 'between', value1: '1', value2: '10', ignoreBlank: true, inCellDropdown: false, alert: { style: 'stop' } }],\n conditionalFormats: [{ id, rects, kind: 'cellIs', operator: 'greater', value1: '50000', style: { fill: '#FFC7CE', color: '#9C0006' } }],\n },\n },\n}\n```\n\nRows are `r` plus the 0-based index and columns are letters, a format key is the two with a\nspace between; rectangles are `[minRow, minCol, maxRow, maxCol]`,\n0-based. A hidden row keeps the height it had before it was hidden, not\nthe 0 it shows while hidden, so an Unhide after a restore brings it back\nat its size.\n\n## A component, not a mode\n\nKanban, the scheduler and charts are modes of `<SvGrid>` (`board`,\n`scheduler`, `chart`) because they are different pictures of the same\nrows: the grid keeps the data, the row model, sorting and filtering, and the\nmode draws cards, a calendar or a chart from them. The spreadsheet is the\nother way round: the same picture, a table, over a different data model. It\nowns a document (formulas, address-keyed formats, comments, rules) and uses\nthe grid as its rendering primitive, which is why it is a component that\ncomposes `<SvGrid>` rather than a prop on it. `installEnterprise()` enables\nit along with everything else; there is nothing extra to register.\n\n## Rows into a sheet\n\n`sheetCellsFromRows(rows, fields, { totals })` turns an array of records\ninto the cells `data` or `createWorkbook` take: a header row of labels, a\nrow per record with each value as the text the engine reads (a number as\nits digits, a boolean as `TRUE` or `FALSE`, a `Date` as `yyyy-mm-dd`,\nnothing for null), and with `totals` a last row with `=SUM(...)` under\nevery column that held a number. It is how a grid's rows, or an API's,\nbecome a sheet the user can add formulas to; Studio's Spreadsheet block\nis this over the screen's dataset.\n\n```svelte\n<script lang=\"ts\">\n import { SvSheet, sheetCellsFromRows } from '@svgrid/enterprise'\n\n const cells = sheetCellsFromRows(orders, [{ field: 'item', label: 'Item' }, 'qty', { field: 'price', label: 'Price' }], { totals: true })\n</script>\n\n<SvSheet data={[{ name: 'Orders', cells }]} />\n```\n\n## Writing to the workbook from outside\n\nEvery edit made through the shell repaints it: typing, the ribbon, a\nshortcut, the fill handle, and `cmd.setCellValue` from an `onAction`\nhandler, which is also how a dialog should write a cell - the write lands in\nthe grid's undo history and the shell sees it.\n\nA write the shell cannot see does not repaint: a Name Manager redefining a\nname, an import replacing a sheet, a solver applying its answer with\n`wb.setRaw`. Call `refresh()` on the component afterwards:\n\n```svelte\n<script>\n let sheet\n</script>\n\n<SvSheet bind:this={sheet} workbook={wb} onAction={(action, cmd) => {\n if (action === 'name-manager') { openNameManager(wb.names, () => sheet.refresh()); return true }\n}} />\n```\n\n`act(action)` runs a ribbon action as if its button had been clicked\n(`sheet.act('sort-asc')`, `sheet.act('circle-invalid')`), for a host with\nchrome of its own; what the ribbon would raise for `onAction` is raised\nhere too.\n\nThe shell carries Goal Seek, Text to Columns, Remove Duplicates and the\nName Manager itself. The demos below each replace one with a dialog of\ntheir own, returning `true` from `onAction`, and show the write paths a\nreplacement should use: Goal Seek applies through `cmd.setCellValue`, Text\nto Columns and Remove Duplicates write inside `cmd.batch` so each is one\nCtrl+Z, and the Name Manager calls `refresh()`.\n\n<div data-docs-demo=\"457-goal-seek\" data-height=\"600\"></div>\n\n<div data-docs-demo=\"458-data-cleanup\" data-height=\"560\"></div>\n\n## Theming\n\nEvery part of the shell is painted with the grid's `--sg-*` tokens, so\nby default it follows the app's theme the way the grid does: the accent\non the selection and the active tab, the header and ribbon grounds, the\nfont and the corner radius are the host's. The icons are one set of line\nglyphs drawn in the text colour, black on a light ribbon and white on a\ndark one, as Excel's monochrome set is; the only colour on the ribbon is\nthe bar under Fill Colour and Font Colour, which is the colour the button\nwill apply.\n\n`look=\"excel\"` pins Excel's palette instead: the shell sets the tokens on\nits own root to Office's light theme, or its dark theme when the page is\ndark, so it reads as Excel whatever theme the app around it runs, and the\ngrid inside picks the same tokens up. Nothing else changes: the geometry,\nthe icons and the layout are the same either way.\n\n## Using the parts separately\n\n`SvSheet` is a composition of exported components, so anything it does you\ncan do by hand when you want a different arrangement:\n\n```svelte\n<SvSheetRibbon cmd={() => api?.getCommandContext() ?? null} onChange={refresh} />\n<SvFormulaBar {active} value={raw} onCommit={commit} />\n<SvGrid ... />\n<SvSheetTabs workbook={wb} {version} onChange={refresh} />\n```\n\n`api.getCommandContext()` is the seam that makes this work. A keyboard\ncommand is handed a context on every keystroke; a ribbon button has no\nkeystroke to ride in on, and without it every action would have to be\nre-implemented against the public api and kept in step by hand.\n\nFormulas > Formula Auditing has Excel's arrows: Trace Precedents draws\na blue arrow from each cell the active formula reads to the formula, a\ndot on the cell read and an arrowhead on the formula, and pressing it\nagain traces one more level out; Trace Dependents draws the arrows the\nother way, to the formulas that read the active cell, every formula in\nthe workbook counted whether or not it has been on screen; Remove Arrows\nclears them, as does a switch to another sheet. Cells on other sheets are\nleft out, since there is nothing on this sheet to draw to. The arrows\nare measured from the rendered cells and follow every scroll and\nrepaint; they are not part of the document. `wb.precedents(sheet, row,\ncol)` and `wb.dependents(sheet, row, col)` are the two readings on the\nworkbook. Raised as `trace-precedents`, `trace-dependents` and\n`remove-arrows`.\n\nThe same group carries the two dialogs that answer \"why does this cell say\nthat?\". **Evaluate Formula** shows the active cell's formula with one part\nunderlined, and each click on Evaluate replaces that part with what it is\nworth, until the formula has collapsed into the cell's answer; Step Back and\nRestart walk it again. A branch of an `IF` that is not taken is never\nevaluated, so it never reports an error the cell does not have, and a range\nsuch as `A1:A9` stays where it is because a block is not a value.\n\n**Error Checking** walks the cells on the sheet worth a second look, one at\na time, with the selection following: every formula whose value is an error,\nsaid in a sentence rather than a code, and every formula that breaks the\npattern of the ones above and below it. That second check is deliberately\nquiet. A formula is only called the odd one out when the cells above and\nbelow it both hold formulas, those two agree with each other once\ntranslated, and this one does not, which is the shape of a column filled\ndown and then broken in the middle. Show Calculation Steps hands the cell\nstraight to Evaluate Formula.\n\nBoth are engine functions first, so a save-time check or a report can use\nthem with no browser:\n\n```ts\nimport { evaluationSteps, checkSheet } from '@svgrid/enterprise'\n\nconst steps = evaluationSteps(parseFormula(text), (part) => wb.evaluateText(sheet, part))\nconst problems = checkSheet(sheet, {\n rowCount: () => wb.rowCount(sheet), colCount: () => wb.colCount(sheet),\n getRaw: (r, c) => wb.getRaw(sheet, r, c), getValue: (r, c) => wb.getValue(sheet, r, c),\n})\n```\n\nRaised as `evaluate-formula` and `error-checking`.\n\n## The dialogs\n\nFour of Excel's dialogs are the shell's own, so nothing on the ribbon or in\nthe cell menu is a button that does nothing:\n\n| Dialog | Opens from |\n| ------ | ---------- |\n| Find and Replace | `Ctrl+F` or `Ctrl+H`, Find & Select on the ribbon. Find Next, Find All, Replace, Replace All; match case, whole cell, look in values or formulas. Replace All is one undo. |\n| Paste Special | `Ctrl+Shift+V`, the Clipboard group's launcher, the last entry under the Paste arrow, the cell menu. All / Formulas / Values / Formats, Add / Subtract / Multiply / Divide, Skip blanks, Transpose. Works on what Ctrl+C took from the sheet. |\n| Chart | A double-click on a chart, or Insert > Setup while one is selected. Type (Column, Line, Area, Pie, Scatter), title, series in columns or rows, whether the first row and column are labels, stacking, a trendline over every series and one series on a secondary axis; Delete removes the chart. |\n| Page Setup | Page Layout > Print Titles, the Page Setup group's launcher. Orientation, paper, margins, scale, print area, rows to repeat at top, gridlines and headings; Print... applies and prints. |\n| Protect Sheet | Review > Protect Sheet. Excel's \"allow all users of this worksheet to\" list; OK protects with what is ticked. |\n| Allow Edit Ranges | Review > Allow Edit Ranges. Titled blocks that take an edit on a protected sheet; New over the selection, Modify, Delete, Protect Sheet... |\n| Format Cells | `Ctrl+1`, the launchers on the Font, Alignment and Number groups, the cell menu, and the end of Home > Cells > Format. Number (category, decimals, separator, the Accounting symbol, the Special type, custom code, live sample), Alignment (horizontal, vertical top/middle/bottom, wrap, indent), Font, Border presets, Fill, Protection. Opens on the active cell's format and applies only what was changed to the whole selection, as one undo. |\n| Insert Function | the `fx` button. Search or pick a category, read the signature and what the function does; OK starts the cell on `=NAME(` with the caret inside. |\n| Name Manager | Formulas > Name Manager, `Ctrl+F3`. Every defined name with what it refers to and its value; edit, delete, add. |\n| Goal Seek | Data > Goal Seek. Set a formula cell to a value by changing one input; the status page shows the answer and OK keeps it as one undo. |\n| Create Table | Insert > Table, `Ctrl+T`, and again on a cell inside a table, which is how one is renamed, resized or restyled. The range (the selection, or the region around the active cell), the name its columns are read by, whether the first row is the header, a totals row, and the styles gallery. A totals row asked for is ADDED under the data, as Excel's Total Row is, with Total in the first column and `=SUBTOTAL(109,[Last column])` in the last; unticked, it goes. A structured reference is a range to SUBTOTAL, so a totals row's formulas skip the rows a filter folds. Insert > Table Styles opens the same dialog on the table the cursor is in. |\n| Evaluate Formula | Formulas > Evaluate Formula. The active cell's formula with the next part underlined; Evaluate replaces it with its value, Step Back and Restart walk it again. |\n| Error Checking | Formulas > Error Checking. Every cell on the sheet that reports an error, and every formula that breaks its column's pattern, walked with Previous and Next; Show Calculation Steps opens Evaluate Formula on the cell. |\n| Calculation Options | Formulas > Calculation Options. Excel's Enable iterative calculation, with the maximum passes and the smallest change worth another one; OK recalculates, so a circular reference goes from #CYCLE! to its fixed point, or back. See Iterative calculation below. |\n| Sort | Data > Sort. A level per key, each a column (named from the header row when \"My data has headers\" is on; the guess looks across every column of the block, so a Name column over names still reads as a header when the Pay column beside it is text over numbers, as Excel guesses it), what to sort on and an order; Add Level and Delete Level; the block is the selection or the region around the active cell. Numbers sort before text, blanks go last, ties keep their order, formats and one-row merges ride with their rows, and it is one undo. A level can sort on the cell colour or the font colour instead of the value: the list offers the colours that column carries, and the one picked goes On Top or On Bottom while every other row keeps its order, which is Excel's model, because two colours are not greater or lesser than one another. Sort A to Z and Z to A beside it sort on the active cell's column. |\n| Text to Columns | Data > Text to Columns. The delimiter is guessed from the column, the preview shows the split, Finish writes it as one undo. |\n| Remove Duplicates | Data > Remove Duplicates. Tick the columns that decide a duplicate, say whether the first row is headers; the count goes to the status bar. |\n| Data Validation | Data > Data Validation. Settings (Allow, Data, the bounds or the source, Ignore blank, In-cell dropdown) and Error Alert (Style, Title, Message); OK puts one rule over the selection, Clear All removes it. See Data validation below. |\n| Conditional formatting | Home > Styles > Conditional Formatting: Greater Than..., Less Than..., Between..., Equal To..., Text that Contains..., Duplicate Values..., Top 10 Items..., Bottom 10 Items..., Above Average..., Below Average... and New Rule > Use a Formula... each open the small dialog (the value or values, or the formula, and the \"with\" style); Manage Rules... opens the Rules Manager. See Conditional formatting below. |\n\nHome > Cells > Insert and Delete are Excel's split buttons. The face takes\nthe selection's axis: a whole column selected inserts or deletes columns,\nanything else rows, which is what Excel does with a cell selected. The\narrow spells the axis out, so a column goes in from a cell without\nselecting the column first: Insert Sheet Rows, Insert Sheet Columns and\nInsert Sheet (Shift+F11); Delete Sheet Rows, Delete Sheet Columns and\nDelete Sheet, which asks the tab menu's question first when the sheet\nholds anything (raised as `delete-sheet`). The cell menu and the column\nand row header menus offer the same Insert and Delete for their axis, and\nCtrl+Shift++ and Ctrl+- take a whole row or column selection.\n\nHome > Cells > Format is Excel's menu: Row Height..., AutoFit Row Height,\nColumn Width... and AutoFit Column Width for the rows and columns the\nselection spans (`row-height`, `autofit-rows`, `column-width`,\n`autofit-columns`; an AutoFit is one undo), Hide Rows, Hide Columns, Unhide\nRows and Unhide Columns with their keys, Lock Cell under Protection (a\ntoggle, lit while the active cell is locked, raised as `toggle-lock`), and\nFormat Cells... at the end. A ribbon item of kind `dropdown` is that shape:\nthe face opens the list and every entry raises its own action.\n\n### AutoFilter\n\nExcel's Filter: Ctrl+Shift+L, Home > Editing > Filter or Data > Filter puts\nan arrow on every header cell of the current region (the block around the\nactive cell, its first row the headers; a selected range is the region\ninstead). An arrow drops Excel's menu: Sort A to Z and Z to A (Smallest to\nLargest on a column of numbers), Clear Filter From the column, Filter by\nColor when the column's cells carry more than one fill (its own or a\nconditional format's, No Fill included), Date Filters on a column of\ndates (Today, This Week, Last Month, This Quarter, Year to Date and the\nrest, or Equals, Before, After and Between with a typed date; weeks run\nSunday to Saturday), Top 10 on a column of numbers (top or bottom, so\nmany items or so many percent, counted over the whole column), Text\nFilters or Number Filters (equals, does not equal, begins with, contains,\ngreater than, between and the rest, two of them joined with And or Or), a\nsearch box, (Select All) and the column's values with their counts, the\nblanks last. A search narrows the list and OK (or Enter in the box)\napplies what it shows, as Excel does; Alt+Down on a header cell drops the\nmenu. OK folds the rows that fail; the arrow turns into a funnel,\nthe row numbers of the region turn blue and the status bar reads \"N of M\nrecords found\". A folded row is the filter's, not a hidden row: Unhide\nleaves it, `getState().hidden` leaves it out, and Filter off (Ctrl+Shift+L\nagain) shows it while a row hidden by hand stays hidden. The rows are\nworked out again after every change, so a formula that drops out of a\nNumber Filter folds away and one that comes back returns, as Excel's\nReapply would; a sort from the menu keeps the header row where it is.\nConditions run through the grid's own Excel-filter compiler\n(`@svgrid/grid/filtering`), so the operators are the ones the grid's\nfilter row has. The filter is per sheet, moves with an insert or delete,\nrides in `getState()` as `autoFilter` (`{ range, filters }`, a filter\nbeing `values`, `condition`, `date`, `color` or `top`) and reports\n`{ kind: 'filter' }` on `onChange`. Raised as `toggle-filter`.\n`hiddenRowsFor` takes a `fillAt` reader for the colour filters and a\n`today` for the date periods; `datePeriodBounds` is the calendar on its\nown.\n\n### Merged cells\n\nExcel's Merge & Center, on Home > Alignment as Excel's split button: the\nface merges the selection into one cell and centres it, the arrow opens\nMerge & Center, Merge Across (one merge per row), Merge Cells (no centring)\nand Unmerge Cells; the cell menu offers Merge & Center on a range and\nUnmerge Cells on a merge, and Merge & Center on a merged cell unmerges it.\nA merge keeps the top-left cell's value and drops the rest, so a merge\nover values asks first, with Excel's words; the clears, the centring and\nthe merge are one undo. The grid draws a merge as one cell (its\n`mergedCells`): it is selected as one, the arrow keys step over it, a\nclick anywhere on it lands on its top-left cell, which is what the formula\nbar shows and an edit writes, and the covered cells take no entry, fill or\npaste. A merge moves with an insert or delete (and grows when rows are\ninserted inside it), a one-row merge rides with its row through a sort,\nand a sort over a range a merge crosses is refused with Excel's sentence.\nMerges are per sheet, ride in `getState()` as `merges`\n(`[minRow, minCol, maxRow, maxCol]`) and report `{ kind: 'merges' }` on\n`onChange`. Raised as `merge-center`, `merge-across`, `merge-cells`\nand `unmerge-cells`.\n\n### Conditional formatting\n\nExcel's rules that colour cells by what they hold, on Home > Styles >\nConditional Formatting. Highlight Cells Rules (Greater Than, Less Than,\nBetween, Equal To, Text that Contains, Duplicate Values) and Top/Bottom\nRules (Top 10 Items, Bottom 10 Items, Above Average, Below Average) open\nthe small dialog: the value or values (a literal or a formula, so\n`=$B$1` follows B1) and the \"with\" style, Excel's Light Red Fill with\nDark Red Text and the rest. Data Bar, the Green - Yellow - Red and Green -\nWhite Color Scales and the Icon Set (3 Arrows) apply to the selection at\nonce; a data bar measures from zero, as Excel's do, so 10, 20 and 40 draw\na quarter, a half and the full width. Clear Rules from Selected Cells cuts the selection out of every rule\nit touches; Clear Rules from Entire Sheet empties the sheet's rules.\nManage Rules... is the Rules Manager: every rule in priority order with what\nit does, a sample and where it applies, Edit Rule (for the rules the\ndialog can edit), Delete Rule, Move Up, Move Down and Stop If True, for\nthis worksheet or the current selection; OK applies the lot as one undo.\n\nRules are evaluated over the COMPUTED values, so a Greater Than on a\ncolumn of formulas follows their results and a cell drops out when an\ninput changes. Priority is the Rules Manager's order (a new rule goes\nfirst, as in Excel): the first rule that decides a property (the fill,\nthe text colour, the bar, the icon) keeps it, and a rule with Stop If True\nstops the rules below it for the cells it matches. A rule's fill and text\ncolour paint over the cell's own format, as they do in Excel. The rules\nare per sheet, move with an insert or delete, ride in `getState()` as\n`conditionalFormats` and report `{ kind: 'conditional-formats' }` on\n`onChange`.\n\nNew Rule > Use a Formula... is Excel's \"format values where this formula\nis true\": the formula is written for the top-left cell of the selection\nand moves with each cell as a copied formula would, so `=$B2>100` on\nA2:A9 reads each row's B, and TRUE (or a number other than 0) formats.\nThe rule is `{ kind: 'formula', formula, style }`. A data bar over a range\nwith negatives puts its axis where zero falls between the minimum and the\nmaximum, and the bars grow away from it: right for a positive value, left\nand in red (or the rule's `negativeColor`) for a negative one. `evaluateCf`, `ruleStats`, `removeCf`, `shiftCf`,\n`describeCf` and the rule types are exported from\n`@svgrid/enterprise/sheet`; the grid's own value-driven\n[conditional formatting](./conditional-formatting.md) is a different thing\n(rules by column over raw values) and the shell does not use it.\n\nFreeze Panes lives on the View tab, Excel's dropdown: Freeze Panes (at the\nactive cell), Freeze Top Row, Freeze First Column, Unfreeze Panes\n(`freeze-panes`, `freeze-top-row`, `freeze-first-column`,\n`unfreeze-panes`). Beside it, View > Show has Excel's three toggles:\nGridlines, Formula Bar and Headings (the row numbers and column letters),\nraised as `toggle-gridlines`, `toggle-formula-bar` and\n`toggle-headings`. They are view settings of the component, not part of\nthe document, so `getState()` leaves them out.\n\n### Data validation\n\nExcel's rules over cells: Data > Data Validation opens on the rule at the\nactive cell and puts one over the selection. Allow is Any value, Whole\nnumber, Decimal, List, Date, Text length or Custom; the bounds (between,\nnot between, equal to, greater than and the rest) are literals or\nformulas, so `=$B$1` as a maximum follows B1 and a relative `=A2` on\nG2:G17 reads the same row's A, as in Excel; a List is a comma list or a\nrange or a name starting with `=`; a Custom formula is written for the\ntop-left cell of the selection and moves with each cell, TRUE (or a number\nother than 0) allowing the entry. A typed entry that breaks the rule never\nreaches the workbook: the alert box shows the rule's title and message (or\nExcel's own words) with Retry, which reopens the editor with the entry,\nand Cancel; a Warning rule asks \"Continue?\" and Yes lets the entry through.\nThe formula bar is checked the same way. Only typed entries are checked,\nas in Excel: paste, fill, Ctrl+D and the commands write what they are\ngiven.\n\nA List rule with In-cell dropdown draws an arrow on the active cell; the\narrow, or Alt+Down, drops the list (arrows and Enter pick, Escape leaves\nthe cell alone), and a pick is one undo. A typed entry is matched against\nthe list without regard to case.\n\nThe dialog's Input Message tab gives a rule a title and a message that\nshow in a small box under the cell while it is selected, as Excel's do;\nthe rule carries it as `input`. The arrow beside Data Validation has\nCircle Invalid Data, which draws Excel's red oval on every cell under a\nrule whose current contents break it (pasted, filled or written before the\nrule was), and Clear Validation Circles. The circles are worked out again\non every repaint while they are on, so a corrected cell loses its circle\nat once; switching sheets turns them off. `invalidCells` is the same check\nfor a shell of your own. Raised as `circle-invalid` and `clear-circles`.\n\nThe rules are per sheet, move with an insert or delete (and drop when\ntheir cells go), ride in `getState()` as `validation` and report\n`{ kind: 'validation' }` on `onChange`. `ruleAt`, `checkEntry`,\n`listChoices`, `removeValidation` and `shiftValidation` are exported\nfrom `@svgrid/enterprise/sheet` for a shell of your own, with\n`wb.evaluateText` and `wb.evaluateRange` evaluating a bound or a list\nsource in the sheet without storing it. Raised as `data-validation` and\n`open-list`.\n\n### Objects: charts and pictures\n\nInsert > Chart charts the selected block, or the region of data around\na single selected cell, as Excel starts one: the first row and column are\nread as the labels when they look like labels, each column of the block\nis a series, and the chart is anchored just under the block. It reads the\n**range**, not a copy of the numbers, so editing a cell redraws it.\nInsert > Picture puts an image on the sheet from a file, carried in the\ndocument as a data URL. A file the .xlsx cannot hold as it is (an SVG, say:\nExcel keeps only a few raster types in `xl/media`) is drawn to a PNG on\nthe way in, so what is on the sheet is what the file will carry; a picture\nwhose source is a web address is left out of the file, and Save As says\nso in the status bar.\n\n**`=IMAGE(source, [alt])`** is the other kind of picture, and the\ndifference is the point: an `IMAGE` cell IS the picture rather than\nfloating over one. It sorts with its row, filters with it, copies as a\nformula and moves when the cells move, with nothing to keep in step, which\nis what a catalogue with a thumbnail column wants. The source is a web\naddress or a `data:` URL; anything else stays text rather than becoming a\nbroken image. The second argument is the alt text, worked out like any\nother argument, so a screen reader is told what the picture is. The\nfunction's own value is the source, so a cell that reads it gets an\naddress rather than a picture it cannot use, and the file stores it as\n`_xlfn.IMAGE`, which is where Excel keeps it.\n\nAn object floats over the cells rather than living in them: drag it to\nmove, drag its corner to resize, press Delete to remove it, and\ndouble-click a chart (or Insert > Setup) to open the Chart dialog, where a\ntrendline (linear or a three-point moving average) goes over every series\nand one series can be moved to a secondary axis on the right, which is\nwhat makes a revenue-and-margin chart readable when the two are orders of\nmagnitude apart. Each\nchange is one undo. It hangs from a cell and an offset inside it, the way\nExcel's does, so inserting a row above moves it, deleting that row takes\nit with it, and resizing a column under it moves it without reshaping it.\n\nObjects are per sheet, ride in `getState()` as `objects` and report\n`{ kind: 'objects' }` on `onChange`. The chart's own drawing is the free\n`<SvChart>` from `@svgrid/grid`, so its types, palette and tooltips are\nthe grid's. Raised as `insert-chart`, `insert-picture`, `chart-setup`\nand `delete-object`, so an application can put its own chart builder in\ntheir place.\n\nObjects print with the sheet. File > Print hangs each one from its anchor\ncell with the offset and size it has on the page, and the default print\narea grows down and across to hold a chart anchored below the numbers,\nwhich is where a chart usually is. A named print area is honoured exactly\nas it stands, so an object outside it is left out.\n\nObjects ride in the .xlsx both ways, as Excel's own drawing part. A\npicture's bytes go into `xl/media` and a chart becomes a chart part of its\nown, both anchored to their cell; a file being opened gives its pictures\nand charts back the same way. A chart part carries the REFERENCES its\nseries read rather than a copy of the numbers, so Excel redraws it from\nthe cells beside it instead of from a snapshot that can drift. The one\nthing left behind is a picture whose source is a URL rather than a `data:`\nURL: its bytes are somewhere else, and a file with a broken image in it is\nworse than one without the image.\n\n### Hyperlinks\n\nTwo ways to put a link in a cell, both Excel's.\n\n**Insert > Link**, or Ctrl+K, puts a link ON the cell: the address, the\ntext to display, and a ScreenTip. The cell keeps whatever it says and the\nlink is kept beside it, so editing the text keeps the link, a format\nchange keeps it, and clearing the cell takes it away. Insert > Remove\ntakes the links off the selection. The cell menu has Excel's entries too:\nLink... on a plain cell; Edit Link..., Open Link and Remove Link on one\nthat carries a link. Each is one undo.\n\n**`=HYPERLINK(link, [friendly])`** puts one in a formula, showing the\nfriendly name. A cell holding that formula is clickable too, and its\ntarget is whatever the first argument works out to, so a link built from\nits neighbours (`\"…/issues/\" & A2`) goes where it says.\n\nA target that reads like an address moves the selection instead of leaving\nthe page: `Sheet2!B4`, `B4`, or a defined name, switching sheets when it\nhas to. Anything with a scheme, or a bare `www.`, opens in a new tab. A\nsingle click follows the link and a drag from the same cell selects, which\nis Excel's rule.\n\nOnly the schemes a link can sensibly mean are followed: `http`, `https`,\n`mailto`, `tel`, `sms`, `ftp` and `ftps`. A target carrying any other one,\n`javascript:` among them, is not a link at all: the cell keeps its text,\nnothing is clickable, the target is never written into a saved file, and one\nfound in a file being opened is dropped on the way in rather than carried\ninto the document. A\nlink is data, arriving from a colleague, a delta stream or an opened file,\nso what may be followed is named rather than assumed. Insert > Link refuses\nsuch an address in the dialog and names the ones a cell does open, and\nclicking a cell that already carries one says the same thing in the status\nbar rather than pretending the address is empty or somewhere on this\nworkbook.\n\nLinks are per sheet, ride in `getState()` as `links`, report\n`{ kind: 'links' }` on `onChange`, and move with an insert or a delete.\nThey go into the .xlsx as real hyperlinks, an external one as a\nrelationship with `TargetMode=\"External\"` and an internal one as a\n`location`, tooltips included, and a file opened back carries both.\nRaised as `insert-link` and `remove-link`.\n\n### Iterative calculation\n\nA circular reference is normally an error, and the shell says so: every\ncell in the loop shows `#CYCLE!` while the rest of the sheet keeps working.\nSome models are circular on purpose, because the answer is a fixed point: a\nbonus that is a tenth of the profit the bonus is taken out of, interest\ncharged on the balance the interest is part of.\n\n**Formulas > Calculation Options** is Excel's switch for those, raised from\nFile > Options because it is the one setting there that changes what a\nformula is worth. It takes the maximum number of passes over the loop, 100\nby default, and the smallest change worth another pass, 0.001 by default.\nWith it on, the loop runs from the values it last had until the numbers\nstop moving or the passes run out, and every cell in it holds a number\ninstead of an error.\n\nIt is workbook-wide rather than per sheet, so `wb.iteration` reads it and\n`wb.setIteration({ enabled: true })` sets it from code, recalculating as it\ngoes. It rides in `getState()` beside the defined names and the tables, and\nit goes into the .xlsx as `calcPr`, which is where Excel keeps it, so a\nfile saved with iteration on opens with it on. Raised as `calc-options`.\n\n### PivotTable from a range\n\nInsert > PivotTable summarises the selected block, or the region around\na single selected cell, on the same pivot engine the grid uses for its own\npivot mode. The dialog takes the source block\n(its first row the field names), where the result goes, and which field is\na row, a column or a measure, with Sum, Average, Count, Distinct count,\nMin and Max to summarise by.\n\nWhat the sheet keeps is the **definition**; what it writes is **cells**. A\npivot's result is an ordinary block, so it can be formatted, charted,\nfiltered, printed and saved to an .xlsx like anything else you typed.\nInsert > Refresh rebuilds it from the source and clears whatever the last\none wrote, which is what a pivot over live cells owes the user. With the\ncursor inside a written block, Insert > PivotTable opens the dialog on\nthat pivot instead of making another, so moving a field or changing an\naggregation is two clicks. Each write is one undo, cells and definition\ntogether.\n\nA field placed in **Filter** narrows the whole pivot to one of its values.\nThe filter is written above the block, a line per filter with the field\nand the value, so a reader sees what the numbers are of before reading\none; the dialog offers the values that field carries, and typing another\nvalue straight into the cell (or `(All)` to clear it) narrows the pivot at\nonce, since the cell IS the control. The filter applies before anything is\ngrouped, so the totals, the subtotals and Show Details all see the same\nrows.\n\n**Insert > Show Details** is Excel's drill-down: the source rows behind\nthe number in the cell the cursor is in, written to a sheet of their own,\nwith the field names across the top and the first row frozen. A cell in a\nsubtotal line opens its whole group, one in the grand total column opens\nthe whole line, and the grand total opens every row, which is what each of\nthose numbers stands for. The sheet is an ordinary one, so deleting it is\nhow it is dismissed. `pivotDrill` is the same answer as data.\n\nPivots are per sheet, ride in `getState()` as `pivots` and report\n`{ kind: 'pivots' }` on `onChange`. The definition moves with an insert or\na delete, and is dropped when its source or its target cell is deleted.\nRaised as `insert-pivot`, `refresh-pivot` and `pivot-details`.\n\nWith one measure the block is Excel's compact form: the column values\ncarry the corner label, the row labels run down the first column indented\nby level, and the grand total row and column can be turned off in the\ndialog. With two or more measures each column value gets one column per\nmeasure, under a header row naming them.\n\n### Sparklines\n\nExcel's smallest chart, and the one that is not an object: a sparkline IS\nthe cell. Insert > Sparklines offers Line, Column and Win/Loss, each\nopening the Create Sparklines dialog on the selected block with the data\nrange filled in and the location the column just past it. One sparkline\nis drawn per row of the data (per column, when the location is a row),\nread from the range rather than a copy of it, so editing a number redraws\nthe cell.\n\nThey are kept per GROUP, the way Excel keeps them: a data range, a\nlocation range of the same shape and the settings they share. So\n\n- selecting a cell that holds one turns the three kind buttons into a\n change to that group, Excel's Sparkline tab in one press;\n- Insert > Sparklines > Edit reopens the dialog on the group here, for\n its ranges, its colours, whether the whole group is drawn on one value\n scale and whether a line marks its last point;\n- Insert > Sparklines > Clear removes every group the selection touches.\n\nEach of those is one undo. A group moves with an insert or a delete, and\ngoes when the cells it is drawn in, or the cells it reads, are deleted.\nSparklines are per sheet, ride in `getState()` as `sparklines` and report\n`{ kind: 'sparklines' }` on `onChange`. The drawing is the free\n`<SvSparkline>` from `@svgrid/grid`. Raised as `sparkline-line`,\n`sparkline-column`, `sparkline-winloss`, `sparkline-setup` and\n`clear-sparklines`.\n\nA sparkline is behind whatever the cell shows, so a label typed over one\nstill reads, and the pointer goes through it: the cell is selected,\ndragged and edited as a cell. Sparklines ride in the .xlsx as Excel's own\nsparkline groups, both ways, in the worksheet's extension list where Excel\nkeeps them, and they are drawn on the printed page too, in the cells they\nbelong to.\n\n```svelte\n<script>\n const doc = createSheetDocument({ workbook: wb })\n doc.get('Traffic').sparklines = [{\n id: sparklineId(),\n data: [1, 1, 4, 12], // B2:M5, one row per channel\n location: [1, 13, 4, 13], // N2:N5, the cells they are drawn in\n type: 'line',\n markers: true,\n }]\n</script>\n```\n\n### Comments\n\nExcel's notes and its threaded comments: a text on a cell, marked by a red\ncorner and read by hovering the cell, and under it the replies, each with\nits author and time. Review > New Comment, Shift+F2 and the cell menu's\nNew Comment open the box beside the active cell; for a new comment it is\nthe note editor, and Ctrl+Enter or Save closes it, as does Escape or a\nclick elsewhere, keeping what was typed, as Excel keeps a note. On a cell\nthat has one, the box is the thread: the first entry with Edit and Delete,\nthe replies with theirs, a reply box (Ctrl+Enter posts) and Resolve in the\nhead, which greys the thread in the list and hides the reply box until\nReopen. Delete (on Review, in the box, in the cell menu) removes the whole\nthread; Previous and Next walk the sheet's comments row by row and wrap;\nShow All Comments opens a strip under the formula bar listing every\ncomment with its author, each a jump to its cell, which is the honest form\nover a sheet that only paints the rows in view. Every change is one undo.\n\n`commentAuthor` is who signs: with it set, a new comment and every reply\ncarry that name and the time, the way Excel signs a threaded comment with\nthe signed-in user; without it a new comment is a plain note and a reply\ncarries its time alone. Comments are per sheet, move with an insert or\ndelete, ride in `getState()` as `comments` (`r4` -> `B` -> a note's text,\nor `{ text, author, at, replies, resolved }` for a thread; every document\nsaved before threads existed reads as it did) and report\n`{ kind: 'comments' }` on `onChange`. Save As writes a note as Excel's\nlegacy note and a thread as its threaded comment with the persons part,\nand Open reads both. Raised as `new-comment`, `edit-comment`,\n`delete-comment`, `prev-comment`, `next-comment` and `toggle-comments`.\n\n### Protection\n\nExcel's model, without the password. Every cell is locked to begin with;\nFormat Cells > Protection (or Format > Lock Cell) unlocks the ones that may\nchange; Review > Protect Sheet turns the flags on. From then on a locked\ncell refuses to change wherever the change comes from: typing, F2, Delete,\nthe formula bar, paste, a fill, Ctrl+D, Find and Replace (which skips it),\na sort of the region, and the formats (the Font, Alignment and Number\nbuttons grey out while the selection holds a locked cell, and Ctrl+B\ndeclines). Insert and Delete grey out for the whole sheet, so do Row\nHeight, Column Width, AutoFit, Hide and Unhide, and the resize handles go\naway. The refusal is a sentence in the status bar, Excel's own: \"The cell\nyou're trying to change is on a protected sheet. To make a change,\nunprotect the sheet.\" Unlocked cells take every edit as before.\n\nWhat is drawn OVER the cells is covered too: a chart or a picture cannot be\nmoved, resized, retyped or deleted, and the sparklines of a protected sheet\ncannot be changed or cleared. The dialogs that would write them do not open\non a protected sheet, and the refusal sits on the write itself, so every way\nin (the ribbon, a dialog, the keyboard, a drag) says the same thing.\n\nProtect Sheet opens Excel's dialog with its \"allow all users of this\nworksheet to\" list: format cells, format columns, format rows, insert\ncolumns, insert rows, delete columns, delete rows, sort, use AutoFilter.\nWhat is ticked stays open once the sheet is protected: with Format cells\nthe Font, Alignment and Number buttons, Format Cells, merges and Lock\nCell work on every cell; with Format rows or columns Row Height, Column\nWidth, AutoFit, Hide and Unhide and the resize handles come back for that\naxis; the insert and delete entries open Home > Cells > Insert and Delete\nfor their axis; Sort allows a sort of a block that holds no locked cell,\nas Excel's does (a block with one is refused whatever the tick); Use\nAutoFilter allows the arrows on and off and their menus. The list is per\nsheet and kept, so Unprotect and Protect again open on the same ticks.\n\nReview > Allow Edit Ranges is Excel's Allow Users to Edit Ranges: a list\nof titled blocks (`B2:B10`, or several areas with commas) that take an\nedit on a protected sheet whether their cells are locked or not. New adds\none over the selection, Modify edits it in place, Delete removes it, and\nProtect Sheet... goes straight to that dialog. A range moves with an\ninsert or delete and goes with the rows it spans.\n\nProtect Sheet and Unprotect Sheet share one slot on the Review tab\n(`protect-sheet`, `unprotect-sheet`, and `allow-edit-ranges`); each is\none undo, so Ctrl+Z after Unprotect protects again, and after Protect\nputs the old allow list back. The flag is per sheet and rides in\n`getState()` as `protected`, the list and the ranges as\n`protection: { allow, ranges }` beside it (absent in an older document,\nwhich reads as nothing allowed and no ranges), the unlocked cells as\n`locked: false` in their format entries; `onChange` reports\n`{ kind: 'protection' }`. Save As writes the flag, the list and the\nranges as the file's `sheetProtection` and `protectedRanges`, and Open\nreads Excel's. A command written against the grid asks `cmd.canEdit(r, c)`\nbefore it writes and gets the same answer the editor does.\n\nThere is no password, on the sheet or on a range: the protection is\nagainst mistakes, not against the person at the keyboard, who can unprotect\nwith one click.\n\nData > Sort A to Z and Z to A sort the current region (or the selection) by\nthe active cell's column: numbers before text, blanks last, a header row of\ntext over numbers left in place, values and formats moving together, one\nundo.\n\nEvery one of them raises its `onAction` first. A handler that returns\n`true` has taken the action over and the shell's dialog stays closed, so an\napplication can put its own Format Cells in place of this one:\n\n```svelte\n<SvSheet workbook={wb} onAction={(action, cmd) => {\n if (action === 'format-cells') { openMyDialog(cmd); return true }\n}} />\n```\n\n## Grouping rows and columns\n\nData > Group folds a block of rows or columns away behind a button, the way\nExcel's outline does. Group and Ungroup act on the selection, grouping an\nalready grouped run nests it, and the numbered buttons at the corner of the\nbar show the whole sheet at one depth. Auto Outline groups the runs between\nthe totals the sheet already has; Clear Outline removes the lot.\n\nThe summary line is the one the button sits on, and it is the line AFTER\nthe detail on both axes: the total row under its rows, the total column to\nthe right of its columns. That is Excel's `summaryBelow` and `summaryRight`,\nand both round-trip through the .xlsx as `outlineLevel` and `collapsed`\nper line.\n\nA line a collapsed group folds is not a line you hid. The two are kept\napart in the document on purpose: if folding recorded a hidden row, the row\nwould stay invisible after its group reopened and there would be no button\nleft to bring it back. Unhide leaves a folded row to its group, the way it\nalready leaves a filtered row to its filter.\n\n## Cells drawn as a control\n\nInsert > Controls draws the selected cells as a checkbox or a button\ninstead of as text, and `SvSheet`'s model has a radio group beside them.\n\nA control is a rendering and never a second source of truth. A checkbox is\nticked because its cell reads `TRUE`, not because the checkbox remembers\nbeing clicked, so everything else keeps working over it:\n`=COUNTIF(A1:A9, TRUE)` counts the ticks, undo undoes one, a paste sets\none, and the file carries an ordinary boolean. Excel's own cell checkbox\nworks this way too, which is why the default pair is `TRUE` and `FALSE`.\nA sheet that wants `Yes` and `No` says so on the region.\n\nA button reports its press through `onCellAction`:\n\n```svelte\n<SvSheet {data}\n onCellAction={(event) => {\n // `event.action` is the region's own action name, so one column of\n // buttons can be told from another.\n if (event.action === 'approve') approve(event.row)\n }} />\n```\n\nClear Control puts the cells back to ordinary ones and leaves their values\nwhere they are.\n\n## A tab that is a table, not cells\n\nA workbook tab can be a bound table rather than a cell grid. Name it in\n`gridSheets` with its fields and its records and it renders as a `<SvGrid>`,\nwith its own headers, sorting, filtering and inline editing, in the same\nworkbook as the cell sheets beside it.\n\n```svelte\n<SvSheet\n data={[{ name: 'Orders', cells: [] }, { name: 'Summary', cells: [] }]}\n gridSheets={{\n Orders: {\n fields: [\n { field: 'item', label: 'Item' },\n { field: 'qty', label: 'Qty', type: 'number' },\n { field: 'price', label: 'Price', type: 'number' },\n ],\n rows: orders,\n editable: true,\n },\n }} />\n```\n\nThe records are the truth, and after every change they are projected into\nthe workbook's cells, header row included. That is what makes a bound tab\nmore than an embedded widget: on the Summary tab,\n\n```\n=SUM(Orders!B2:B99)\n=VLOOKUP(\"Gadget\", Orders!A2:C99, 2, FALSE)\n```\n\nboth work, because the formula engine, the dependency graph, the file\nwriters and the printer are never told that the Orders tab is different.\nThe projection goes through the same write an ordinary edit uses, which\nignores a write that changes nothing, so editing one field of one record\nrewrites one cell and recalculates only what read it.\n\nWhat a bound tab deliberately does not do is hold formulas of its own. Its\ncells are a rendering of its records, so anything typed into them would be\noverwritten by the next projection. A sheet that needs formulas beside the\ndata is a cell sheet reading the bound one across, which is what the\nexample above is.\n\n## Localisation\n\nEvery string the shell shows is English by default and lives in one flat\nmap, `SheetMessages`, the way the grid's `GridMessages` does. Pass any\nsubset as `localization.text` and unset keys stay English; `locale` is the\nBCP-47 tag the status bar's Sum, Average and Count are printed with, and\nit reaches the grid underneath for its own matching and formatting.\n\n```svelte\n<SvSheet {data}\n localization={{\n locale: 'de-DE',\n text: {\n 'ribbon.tab.home': 'Start',\n 'ribbon.bold.title': 'Fett',\n statusReady: 'Bereit',\n statusRecordsFound: '{shown} von {total} Datensätzen gefunden',\n 'formatCells.title': 'Zellen formatieren',\n ok: 'OK', cancel: 'Abbrechen',\n },\n }} />\n```\n\nThree families of key:\n\n- **The ribbon's** are read off the ribbon model, so every tab, group and\n button is covered without a second list: `ribbon.tab.<tab>`,\n `ribbon.group.<group>`, `ribbon.<item>.label`, `ribbon.<item>.title`\n (the tooltip), `ribbon.<item>.option.<value>` for a dropdown's entries\n and `ribbon.<item>.none` for its \"no fill\" kind of entry. The ids are\n the `RibbonActionId`s: `ribbon.bold.label`, `ribbon.file-open.title`,\n `ribbon.number-format.option.percent`.\n- **The dialogs'** are `<dialog>.<part>`: `formatCells.title`,\n `filter.dateFilters`, `validation.allow.list`, `findReplace.findNext`,\n `sort.addLevel`, `goalSeek.setCell`, and so on.\n- **The chrome's** are plain camelCase: `statusReady`, `nameBox`,\n `newSheet`, `menuFormatCells`, `ok`, `cancel`, the sentences the status\n bar says (`openedFile`, `sheetProtected`, `noInvalidData`).\n\nA sentence with `{placeholders}` (`statusRecordsFound`, `deleteSheetMessage`,\n`linesToggled`, `textToColumns.status`) lets a translator choose the word\norder; `formatMessage` fills them. `defaultSheetMessages` is exported, so\nthe full English map is there to copy from, and `resolveSheetMessages`\ngives the merged map for an app that wants to read it.\n\nWhat stays English: the function names and error values (`SUM`,\n`#VALUE!`), which Excel keeps in every locale too, the rule descriptions\nthe Conditional Formatting Rules Manager lists, and the font names.\n\n### How numbers and formulas are spelled\n\n`locale` also decides how a number and a formula are WRITTEN. Under\n`de-DE` a cell shows `1.234,5`, the formula bar shows `=ROUND(A1/3; 2)`,\nand `1,5` typed into a cell is the number rather than text. The marks come\nfrom `Intl`, so a locale nobody planned for still comes out right, and\nExcel's own rule decides the separator: wherever the decimal mark is a\ncomma the argument separator is a semicolon, since the comma is busy.\n\nThe document is unaffected, and that is the point. A formula is stored as\n`=SUM(1.5, A1)` whatever the locale, so a file written in Berlin opens in\nBoston and `getState()` means the same thing on every machine. The culture\nis put on when a formula is shown for editing and taken off when one is\ntyped. A `,` typed where the separator is `;` is taken as a separator too,\nbecause a comma with no digit after it cannot be a decimal mark, so an\nEnglish keyboard habit is not punished.\n\nA number FORMAT string stays invariant as well: `#,##0.00` is the pattern\nin every locale, and it is the rendering of that pattern that follows the\nculture.\n\nOverride it where the locale is not the whole story:\n\n```svelte\n<!-- German strings, but the dot for decimals this team is used to. -->\n<SvSheet {data} localization={{ locale: 'de-DE', culture: { decimal: '.' } }} />\n\n<!-- German strings, invariant spelling: for a team sharing files with\n English-speaking colleagues. -->\n<SvSheet {data} localization={{ locale: 'de-DE', culture: false }} />\n```\n\n## Two people on one sheet\n\n`createDeltaStream` turns the document's changes into small messages, and\n`applySheetDelta` applies one that arrived from somewhere else. Put a\nsocket between the two and the sheet is collaborative.\n\n```svelte\n<script>\n import { createSheetDocument, createDeltaStream } from '@svgrid/enterprise'\n\n const doc = createSheetDocument({ sheets })\n const socket = new WebSocket(url)\n const stream = createDeltaStream(doc, {\n onDelta: (delta) => socket.send(JSON.stringify(delta)),\n })\n socket.onmessage = (event) => stream.apply(JSON.parse(event.data))\n // A new participant asks for everything once:\n socket.onopen = () => stream.resync()\n</script>\n\n<SvSheet document={doc} height=\"100%\" />\n```\n\nFive kinds of delta go out, and each is as small as it can be:\n\n- **`cells`** carries the RAW TEXT of the cells that were written, not\n their values. A formula travels as `=SUM(B2:B4)` and the other side works\n out its own answer, so the two cannot disagree about a number and the\n receiving sheet keeps a formula in the cell.\n- **`structure`** carries the insert or delete itself, so both sides\n rewrite their own formulas identically.\n- **`state`** carries the one part of the one sheet that changed, the way\n `getState` serializes it: the formats, the merges, a rule, the objects.\n- **`document`** carries the whole state, for the changes not worth\n describing piecemeal or that cannot be: adding or removing a sheet, a\n restore, and the parts the WORKBOOK keeps rather than a sheet, which are\n its tables, its defined names and its calculation settings. A `state`\n delta is keyed by sheet and carries a sheet's own entry, so none of those\n fit in one: without this the two sides drift silently, and the same\n `=SUM(Orders[Amount])` answers a number on one and `#REF!` on the other.\n `resync()` sends the same kind.\n\nThe shell follows its document, so a delta applied from outside repaints\nthe sheet without anything else being called. `refresh()` remains for a\nwrite made straight to the workbook, which the document never hears about.\n\n**Conflicts are last writer wins, per cell.** A delta carries no version\nand no transform: two people typing in the same cell end on whichever\nmessage arrived last, and both converge on it. Two people typing in\ndifferent cells never conflict, which is the case that actually happens.\nAnything stronger, operational transform or a CRDT, is a different piece of\nwork and is not pretended at here. Applying is idempotent for `cells` and\n`state` and is not for `structure`, so a transport that can deliver twice\nhas to deduplicate.\n\n### Presence: where everyone is\n\nThe deltas carry what people TYPE. Presence carries where they ARE, which\nis what stops two people typing into the same cell.\n\n`presence` is a prop, a list of who else is on this workbook, and the shell\ndraws each person on the active sheet as a thin coloured box around their\nselection with their name on their cursor. `onPresence` fires whenever this\nuser's own selection moves, with the sheet, the rectangle and the active\ncell, which is what an application broadcasts.\n\n```svelte\n<SvSheet\n document={doc}\n presence={others}\n onPresence={(me) => stream.sendPresence({ id: myId, name: myName, ...me })}\n/>\n```\n\nThe fifth delta kind, `presence`, rides the same wire so an application\nneeds only one: `sendPresence` sends it, and it arrives at the receiving\nstream's `onPresence` rather than at the document. That is deliberate.\n**Presence is never part of the document**: not in `getState()`, not in the\n.xlsx, and `applySheetDelta` ignores it. A cursor belongs to a session, and\na file that remembered where someone's cursor was last week would be\nremembering nothing worth keeping.\n\nA person with no `colour` is given a stable one from their id, so a set of\npeers is never all one colour, and anyone who has not been heard from for\nfifteen seconds is dropped, since a closed tab says no goodbye. A cursor\nmoves on every arrow key, so throttle `onPresence` if the transport\ncharges by the message; the shell does not, because it cannot know what\nthe transport costs.\n\n## How big a sheet it holds\n\nMeasured rather than guessed, by `pnpm bench` over the built engine (the\ncases are in `tools/bench/sheet-cases.mjs`, the numbers and the rig in\n[Benchmarks](../benchmarks.md#spreadsheet-engine)). On a CI-class Linux\ncontainer:\n\n| Sheet | Opens in | Retains |\n| ---------------------------------------- | -------- | ------- |\n| 1,000 rows, a formula each | 8 ms | 1.6 MB |\n| 10,000 rows, a formula each | 91 ms | 16.5 MB |\n| 50,000 rows, a formula each | 547 ms | 84.2 MB |\n\nA keystroke costs one evaluation rather than a sheet's worth: the\ndependency graph recomputes what read the cell, and nothing else. What\nmakes an edit expensive is not the size of the sheet but how much of it\none cell feeds. A single `SUM` over 10,000 cells is one evaluation that\nre-reads ten thousand cells (14 ms); a running-balance column, where every\nrow reads the row above, is 10,000 evaluations for one keystroke, and that\nis the true cost of that shape.\n\nCI gates the evaluation counts rather than the milliseconds, since a count\nis the same on every machine.\n\n## Right to left, touch and assistive tech\n\nThe shell carries no direction of its own: set `dir=\"rtl\"` on the page or\non any element above it and everything mirrors, the row gutter and the\nfrozen columns to the right edge, the ribbon and the tab strip in reading\norder, an anchored chart hanging from its cell's right edge and growing\nleftwards. The arrow keys follow the reading order with it, so on a\nright-to-left ribbon ArrowLeft is the next tab.\n\nThe two bands that cannot fit a phone pan with a finger rather than\nclipping: the ribbon scrolls sideways over its groups and over its tab\nlabels, and the cells pan in both axes. The formula bar keeps a long entry\non one line, cut off at the right and scrolling with the caret, until the\nchevron expands it; a one-row box that wrapped would only hide the rest. A tap picks a cell, a second tap on the same cell opens its\neditor, and the formula bar is where the address and the formula are read\nand typed.\n\nFor assistive tech the shell is the bands it looks like: the ribbon is a\ntoolbar whose tab strip is a `tablist` with one Tab stop, the arrows plus\nHome and End moving between tabs with focus following the selection; the\nsheet tabs are a second `tablist` with the same model; the formula bar is\na labelled group whose Name Box reads the active cell as its value; the\ncells are the grid's own `role=\"grid\"`; every dialog takes focus and\ngives it back to the cells on Escape. `tests/e2e/sheet-a11y.spec.ts`,\n`sheet-rtl.spec.ts` and `mobile/sheet-touch.spec.ts` hold the shell to\nall of this in a real browser, the first with an axe audit of every band.\n\nOne thing the shell does not decide: a font colour or a fill the DOCUMENT\nsets is used as it stands, on a dark theme as on a light one. A red on a\ndark canvas is as hard to read here as it is in Excel, and the answer is\nthe same one: change the document's colours, not the app's.\n\n## What it does not do\n\nThe shell is the part of Excel a team's spreadsheet uses every day; these\nare the parts it leaves out, on purpose, so nothing on the ribbon is a\nbutton that does nothing.\n\n- **Tabs:** no track-changes on Review; Page Layout has no themes, no\n page breaks and no header or footer text, and Print has no preview of\n its own beyond the browser's.\n- **Objects** are charts and pictures: a chart is what the Chart dialog\n sets and no more, so there are no per-series colours, no data labels and\n no axis titles. A floating picture whose source is a URL rather than a\n `data:` URL is left out of the .xlsx, since its bytes are not in the\n document to write; a picture in a cell, `=IMAGE(...)`, has no such limit,\n because the formula is what the file carries.\n- **Sparklines** are the three Excel draws: no axis options beyond one\n scale for the group, and no high and low point marks beyond the last one.\n They ride in the .xlsx as Excel's sparkline groups, both ways, and they\n print with the sheet.\n- **A PivotTable** is a definition plus the cells it writes, not a live\n object: no drag-and-drop field list and no slicers, and a report filter\n holds one value at a time rather than a tick list. Refresh brings it up\n to date and Show Details is the drill-down.\n- **Collaboration** is the delta stream and the presence overlay above and\n nothing more: no server, no operational transform, no follow-the-leader\n scrolling, and last writer wins per cell.\n- **The outline** groups, ungroups, collapses and shows a level, and it\n round-trips. What it does not have is Excel's Data > Subtotal, the\n command that inserts `SUBTOTAL` formulas at each break AND groups what\n it just broke up; the grouping half is here, the formula-writing half is\n not. `SUBTOTAL` itself works, so a sheet can write its own.\n- **A cell drawn as a control** carries its VALUE into a file and not its\n control: a checkbox column saves as the `TRUE` and `FALSE` it means, and\n reopens as ordinary cells. The controls live in `getState()`, so an app\n that saves the document keeps them; an app that saves only .xlsx keeps\n the data and loses the decoration.\n- **A bound tab** holds no formulas of its own, since its cells are made\n from its records and would be overwritten. It is not written to the\n .xlsx as an Excel table either: its projection goes out as ordinary\n cells, which is what Excel would show anyway.\n- **The culture** changes how a number and a formula are spelled, not what\n they are called: function names stay English, as they do in the file\n format itself.\n- **Protection** takes no password, on the sheet or on an edit range: it\n guards against mistakes, not against the person at the keyboard.\n- **Validation** checks what is typed; pasted-over cells are left as they\n land until Circle Invalid Data is asked for.\n- **Conditional formatting** has Excel's presets and Excel's \"with\"\n styles, one icon set per flavour, and no colour pickers of its own for\n bars and scales; the grid's own value-driven rules are a separate\n feature.\n- **AutoFilter**'s values list is the whole column, unvirtualised, which\n is what a sheet's region holds.\n- **Files** carry what each format has a place for, so the three are not\n equals: the .xlsx carries the most, the .ods has no charts, pictures,\n validation, conditional formatting or frozen panes, and the .xls\n is the oldest and carries the least, with no charts, pictures,\n sparklines, validation, conditional formatting or comments, a\n sheet of 65,536 rows by 256 columns, and a formula using a function Excel\n 97 never had saved as the value it worked out.\n\n## More examples\n\n### A tab that is a table, and rows that fold away\n\nA workbook tab that holds RECORDS rather than cells: the Orders tab renders as the data grid, with its own headers, sorting, filtering and inline editing, beside ordinary cell sheets. Its records are projected into the workbook's cells, header row included, so the Summary tab reads it with plain formulas: SUM, SUMIF, SUMPRODUCT and VLOOKUP all reach across, because the formula engine is never told the tab is different. Summary also shows Data > Group, with three regional blocks folded under their subtotals and the numbered level buttons at the corner of the outline bar. Edit a Qty on Orders and every figure follows.\n\n<div data-docs-demo=\"498-sheet-bound-tabs\" data-height=\"560\"></div>\n\n### Engineering, statistics, and the locale you type in\n\nFour function families the engine gained at once, worked through a real example of each: a bearing from ATAN2 and DEGREES, a register mask in binary and hex with BITAND, an orchard queried through Excel's criteria-block grammar with DSUM, DCOUNT, DAVERAGE and DGET, and NORM.DIST with its inverse, a confidence interval and the chi-squared and t tails. The picker switches how numbers and formulas are SPELLED: German shows 1.234,5 and =ROUND(A1/3; 2) while the document still stores 1234.5 and a comma, so the file opens anywhere. Column H is drawn as checkboxes whose ticks an ordinary COUNTIF counts.\n\n<div data-docs-demo=\"499-sheet-engineering-stats\" data-height=\"560\"></div>\n\n\n### Review workflow: comments + Protect Sheet\n\nAn expense sheet under review on the Excel shell. It opens protected: every cell is locked except the reviewer's Receipt and Status columns, so a keystroke on an amount is refused and the status bar says why. Three reviewer notes sit on the cells they are about (Excel's red corner): hover, Shift+F2, Review > Next, Show All Comments. Status is a list, the totals by status are SUMIFs, and protection, notes and rules all ride in the document.\n\n<div data-docs-demo=\"460-review-comments-protection\" data-height=\"560\"></div>\n\n### Order intake: data validation\n\nAn order form with Excel's Data Validation on it: Region and Product are lists (the arrow or Alt+Down drops them; Product reads the price list on a second sheet), Qty is a whole number from 1 to 500 with a Stop alert and Retry, Discount is a decimal up to 20% with a Warning you can keep, Ship by is a date on or after the order date through a relative bound (=A2 moves with the row). Unit price is an XLOOKUP, the total follows.\n\n<div data-docs-demo=\"461-order-intake-validation\" data-height=\"560\"></div>\n\n### Regional scorecard: conditional formatting\n\nEight regions, three months, a target, and every colour on the page is a conditional formatting rule over computed values: Highlight Cells on attainment (under 90% red, 100% and over green), a Data Bar and a Top 3 on the quarter, a Green - Yellow - Red Color Scale across the months, an Icon Set on the trend. Edit a month and the arrow, the bar, the fill and the rank all move; Manage Rules lists the six in priority order.\n\n<div data-docs-demo=\"462-regional-scorecard-cf\" data-height=\"560\"></div>\n\n### Budget report: merged headers\n\nA half-year budget report laid out with merged cells the way Excel lays one out: a title merged across the page, Q1 / Q2 / H1 group headers merged over their months, a Line corner merged down two rows, a notes paragraph merged into one wrapped block. A merge is one cell to the grid: click inside Q1 and the Name Box says B2, Merge & Center lights, the arrows step over it and its column letters are shaded. Unmerge and merge again from the ribbon; Ctrl+Z each step.\n\n<div data-docs-demo=\"463-merged-report-headers\" data-height=\"560\"></div>\n\n### Support ticket log: AutoFilter\n\nForty support tickets with Excel's Filter on the header row, opened already filtered to what is still open: the funnel on Status, blue row numbers, \"N of 40 records found\" in the status bar. The arrows drop Excel's menu: sort, Clear Filter, Text and Number Filters with two conditions, a search box, (Select All) and the values with counts. The rows are worked out again after every edit, so a ticket typed Closed folds away at once. Ctrl+Shift+L toggles it.\n\n<div data-docs-demo=\"464-ticket-log-autofilter\" data-height=\"560\"></div>\n\n### Autosave: a document that survives a reload\n\nA project tracker that keeps itself in localStorage: onChange fires once per tick with every kind of change, the demo debounces it and writes getState() as JSON, and the next visit rebuilds the document with createSheetDocument({ state }) and hands it to <SvSheet document>. Add a comment, bold a row, hide a column, type a task, reload: it is all back, rules and merged title and frozen rows included. Reset puts the shipped document back with setState().\n\n<div data-docs-demo=\"465-autosave-document\" data-height=\"560\"></div>\n\n### Paste from Excel: formats and formulas survive\n\nExcel puts an HTML document on the clipboard beside the tab-separated text: formats in a style block keyed by class, formulas in x:fmla, raw numbers in x:num. The sheet reads it, so a pasted block arrives bold, filled, with its number formats, and with its formulas moved to where they landed; Google Sheets' data-sheets-formula flavour reads the same. Two buttons put exactly what Excel and Sheets put on the clipboard; click a cell and Ctrl+V.\n\n<div data-docs-demo=\"466-paste-from-excel\" data-height=\"560\"></div>\n\n### Loan model: PMT, accounting formats and the File tab\n\nA mortgage model built from the sheet's own financial functions: PMT for the payment, IPMT and PPMT for each period's split, NPER and RATE for the term and the rate that fit, SUMPRODUCT for the first year of interest. The money cells wear the Accounting format, Extra Payment carries a validation rule with an Input Message, and Circle Invalid Data rings what breaks it. The File tab is the point: Save As writes the whole model as an .xlsx Excel opens with its formulas, formats and rules, Open reads one back, Export CSV takes the active sheet.\n\n<div data-docs-demo=\"484-loan-model-files\" data-height=\"560\"></div>\n\n### Charts and pictures on the sheet\n\nCharts anchored over the cells the way Excel anchors one: each reads a range rather than a copy of the numbers, so typing into a cell redraws it. Insert > Chart charts the selected block and reads its first row and column as the labels, Insert > Picture puts an image on the sheet, and a double-click opens the Chart dialog for the type, the title, series in columns or rows and stacking. Drag an object to move it, its corner to resize, Delete to remove. It hangs from a cell, so inserting a row above moves it, and it rides in getState().\n\n<div data-docs-demo=\"485-sheet-charts-objects\" data-height=\"560\"></div>\n\n### Sparklines: a chart inside the cell\n\nExcel's smallest chart, and not an object: a sparkline IS the cell. One per row of a block of numbers, drawn from the range rather than a copy, so editing a number redraws it. Insert > Sparklines offers Line, Column and Win/Loss, with Edit for the group's ranges, kind and colours and Clear for the groups the selection touches; selecting a cell that holds one turns the kind buttons into a change to that group. They are kept per group the way Excel keeps them, ride in getState() and move with an insert or a delete.\n\n<div data-docs-demo=\"486-sheet-sparklines\" data-height=\"560\"></div>\n\n### PivotTable from a range\n\nExcel's Insert > PivotTable over a block of cells, on the same pivot engine the grid uses for its own pivot mode. The sheet keeps the definition, the source block, where the result goes and which field is a row, a column or a measure; the result is plain cells written in one undo, so it can be formatted, charted, printed and saved to an .xlsx like any other block. Refresh rebuilds it from the source, opening the dialog from inside one edits it, and Show Details writes the source rows behind a cell to a sheet of their own.\n\n<div data-docs-demo=\"487-sheet-pivot-range\" data-height=\"560\"></div>\n\n### Two people on one sheet\n\nTwo full spreadsheets over two separate documents, wired to each other by createDeltaStream: type in either and the other follows, and each window shows the other's cursor as a coloured box with a name on it. What crosses the wire is a delta rather than the document, and the log shows each one as it goes: a formula travels as its text so the other side works out its own answer, an insert travels as the edit so both rewrite their own formulas, a format travels as the one part of the one sheet that changed. Conflicts are last writer wins, per cell.\n\n<div data-docs-demo=\"488-sheet-collaboration\" data-height=\"620\"></div>\n\n### Evaluate Formula and Error Checking\n\nThe two auditing tools that answer why a cell says what it says, over a commission model with three planted faults. Evaluate Formula underlines one part of the formula and replaces it with its value on each click. Error Checking walks every cell that reports an error, with a sentence on what each one means, and the one row whose formula is not the column's formula. Show Calculation Steps takes one straight into the other.\n\n<div data-docs-demo=\"493-sheet-auditing\" data-height=\"560\"></div>\n\n### Iterative calculation\n\nA circular reference is normally an error, and every cell in the loop shows #CYCLE!. Two models here are circular on purpose: a bonus that is a share of the profit it is taken out of, and interest charged on the balance it is part of. Formulas > Calculation Options turns iteration on with its two limits, and both settle on their fixed point; turn it off and the cycle is an error again. The setting rides in getState() and goes into the .xlsx as calcPr.\n\n<div data-docs-demo=\"492-sheet-iterative\" data-height=\"560\"></div>\n\n### IMAGE: a picture inside the cell\n\nExcel's IMAGE puts a picture IN a cell rather than floating one over it, so it sorts with its row, filters with it, copies as a formula and moves when the cells move. A product list whose thumbnail column reads the swatch beside it: sort by price and the pictures follow their rows. The source is a web address or a data URL, anything else stays text rather than becoming a broken image, and the second argument is the alt text a screen reader reads.\n\n<div data-docs-demo=\"494-sheet-cell-images\" data-height=\"520\"></div>\n\n### Hyperlinks: Insert > Link and HYPERLINK\n\nExcel's two ways of putting a link in a cell. Insert > Link (Ctrl+K) puts one on the cell, so editing the text keeps it and clearing the cell takes it away; the HYPERLINK function puts one in a formula with a friendly name. A target that reads like an address moves the selection instead of leaving the page, so a cell links to another sheet; anything with a scheme opens in a new tab. A single click follows, a drag selects. Links ride in getState(), move with an insert, and go into the .xlsx both ways.\n\n<div data-docs-demo=\"489-sheet-hyperlinks\" data-height=\"560\"></div>\n\n## See also\n\n- [`<sv-sheet>`](../web-components/sv-sheet.md): the shell as a custom\n element for React, Vue, Angular or a plain page, with React and Vue\n wrappers.\n\n- [Excel keyboard shortcuts](./keyboard-shortcuts.md)\n- [Formulas](../spreadsheet-formulas.md)\n- [Number formats](./number-formats.md)\n- [Workbooks](./workbooks.md)\n"
4147
4154
  },
4148
4155
  {
4149
4156
  "slug": "help/cells/styling-cells",
@@ -4444,7 +4451,7 @@ export const docs = [
4444
4451
  "path": "docs/help/editing/validation.md",
4445
4452
  "title": "Validation",
4446
4453
  "section": "Editing",
4447
- "markdown": "# Validation\n\nA column's `validate` hook flags a bad value: the cell turns red and the\nmessage becomes its tooltip. On its own it does not refuse anything, so\na form can be filled in any order and fixed afterwards; `rejectInvalid`\non the same column turns the flag into a refusal, and an edit the rule\nfails against is never written. Rules that need more than one row, or\nthat should be logged, run in `onCellValueChange` instead.\n\n## Flag with `validate`\n\n`validate` runs for every rendered cell, not only on edit, so bad data\nalready in the source is red on load, and it re-runs as the user types.\nIt receives `{ value, row, rowIndex, column }` and returns `null`,\n`undefined` or `true` for a valid value, `false` for invalid without a\nmessage, or a string that becomes the tooltip. Salary, email and age\nbelow each carry a rule; try a salary of 500 or an age of 12.\n\n<div data-docs-demo=\"206-cell-validation\" data-height=\"460\"></div>\n\n```svelte\nconst columns: GridColumns<Person> = [\n { field: 'salary', header: 'Salary', editorType: 'number',\n validate: ({ value }) => (Number(value) < 1000 ? 'Salary must be at least $1,000' : null) },\n { field: 'email', header: 'Email', editorType: 'text',\n validate: ({ value }) => (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value)) ? null : 'Not a valid email address') },\n]\n```\n\nA rule can read the row, so a column can be checked against another:\n`({ value, row }) => (value > row.budget ? 'Over budget' : null)`.\n\n## Refuse with `rejectInvalid`\n\n`rejectInvalid: true` makes the same rule a veto. The value is checked\nafter `valueParser` has run, so the rule sees what would be stored; when\nit fails, the editor closes, the cell keeps its old value, no undo step\nis recorded and `onCellValueChange` does not fire. The red highlight\nstill shows while the value is typed, so the user sees why before Enter\nputs the old value back. Type an age of 200 or an email without an\nat-sign:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; email: string; age: number }\n\n let rows = $state<Person[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', age: 36 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', age: 45 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', age: 54 },\n ])\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 170, editorType: 'text' },\n { field: 'email', header: 'Email', width: 210, editorType: 'text', rejectInvalid: true,\n validate: ({ value }) => (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value)) ? null : 'That is not an email address') },\n { field: 'age', header: 'Age', width: 90, editorType: 'number', rejectInvalid: true,\n validate: ({ value }) => (Number(value) < 16 || Number(value) > 100 ? 'Age must be between 16 and 100' : null) },\n ]\n</script>\n\n<SvGrid data={rows} {columns} editable containerHeight={200} />\n```\n\nThe inline editor, a commit to a selected range and a full-row edit all\ngo through the check. Paste does not open an editor and is not checked;\na pasted block that must be clean is checked in `onCellValueChange`, as\nbelow.\n\n## Built-in soft validation\n\n`parseEditorValue` already does light validation before either hook runs:\n\n- `number`: rejects non-finite results and stores `null`\n- `date` / `datetime`: rejects unparseable strings and stores `null`\n\nThe cell goes blank rather than red. A `validate` rule that treats `null`\nas invalid turns that into a message.\n\n## Rules over the whole row, and a log of rejections\n\nWhen the rule needs to see the row after the edit, or the app wants a\nrecord of what was refused, check the committed value in\n`onCellValueChange` and put the old one back. The event carries the row\nobject, so the revert is one assignment. Try an age of 200 or an email\nwithout an at-sign:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; email: string; age: number }\n\n let rows = $state<Person[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', age: 36 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', age: 45 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', age: 54 },\n ])\n let rejected = $state<string[]>([])\n\n function check(row: Person): string | null {\n if (row.age < 16 || row.age > 100) return 'Age must be between 16 and 100'\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(row.email)) return 'That is not an email address'\n return null\n }\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 170, editorType: 'text' },\n { field: 'email', header: 'Email', width: 210, editorType: 'text' },\n { field: 'age', header: 'Age', width: 90, editorType: 'number' },\n ]\n</script>\n\n<SvGrid\n data={rows}\n {columns}\n editable\n onCellValueChange={(e) => {\n const problem = check(e.row)\n if (!problem) return\n (e.row as Record<string, unknown>)[e.columnId] = e.oldValue\n rejected = [problem, ...rejected].slice(0, 4)\n }}\n/>\n\n<ul>\n {#each rejected as r}<li>{r}</li>{/each}\n</ul>\n```\n\nThe same pattern with a rollback through `api.setCellValue`, a red flash\nand a \"Recent rejections\" panel:\n\n<div data-docs-demo=\"24-validation\" data-height=\"500\"></div>\n\n## Marking the row instead of reverting\n\nReverting is right when the value is unusable. When it is merely wrong,\nkeeping it and marking the row lets someone fix a form in the order they want\nrather than the order the grid demands.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n import type { ConditionalFormat } from '@svgrid/grid'\n\n let rows = $state<Person[]>(seed.map((p) => ({ ...p })))\n\n const bad = (p: Person) => p.age < 16 || p.age > 100\n\n const problems = $derived(rows.filter(bad).length)\n\n const formats = $derived<ConditionalFormat<Person>[]>([\n { type: 'rule', columns: ['age'], when: (ctx) => bad(ctx.row),\n background: '#fee2e2', color: '#991b1b' },\n ])\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180, editorType: 'text' },\n { field: 'age', header: 'Age', width: 100, editorType: 'number' },\n ]\n</script>\n\n<SvGrid data={rows} {columns} editable conditionalFormats={formats} />\n\n<p aria-live=\"polite\">{problems === 0 ? 'All ages valid.' : problems + ' row(s) out of range.'}</p>\n```\n\n## See also\n\n- [Parsing values](./parsing-values.md)\n- [Saving values](./saving-values.md)\n- [demos/05-inline-editing.svelte](../../../examples/src/demos/05-inline-editing.svelte)\n"
4454
+ "markdown": "# Validation\n\nA column's `validate` hook flags a bad value: the cell turns red and the\nmessage becomes its tooltip. On its own it does not refuse anything, so\na form can be filled in any order and fixed afterwards; `rejectInvalid`\non the same column turns the flag into a refusal, and an edit the rule\nfails against is never written. Rules that need more than one row, or\nthat should be logged, run in `onCellValueChange` instead.\n\n## Flag with `validate`\n\n`validate` runs for every rendered cell, not only on edit, so bad data\nalready in the source is red on load, and it re-runs as the user types.\nIt receives `{ value, row, rowIndex, column }` and returns `null`,\n`undefined` or `true` for a valid value, `false` for invalid without a\nmessage, or a string that becomes the tooltip. Salary, email and age\nbelow each carry a rule; try a salary of 500 or an age of 12.\n\n<div data-docs-demo=\"206-cell-validation\" data-height=\"460\"></div>\n\n```svelte\nconst columns: GridColumns<Person> = [\n { field: 'salary', header: 'Salary', editorType: 'number',\n validate: ({ value }) => (Number(value) < 1000 ? 'Salary must be at least $1,000' : null) },\n { field: 'email', header: 'Email', editorType: 'text',\n validate: ({ value }) => (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value)) ? null : 'Not a valid email address') },\n]\n```\n\nA rule can read the row, so a column can be checked against another:\n`({ value, row }) => (value > row.budget ? 'Over budget' : null)`.\n\n## Refuse with `rejectInvalid`\n\n`rejectInvalid: true` makes the same rule a veto. The value is checked\nafter `valueParser` has run, so the rule sees what would be stored; when\nit fails, the editor closes, the cell keeps its old value, no undo step\nis recorded and `onCellValueChange` does not fire. The red highlight\nstill shows while the value is typed, so the user sees why before Enter\nputs the old value back. Type an age of 200 or an email without an\nat-sign:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; email: string; age: number }\n\n let rows = $state<Person[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', age: 36 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', age: 45 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', age: 54 },\n ])\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 170, editorType: 'text' },\n { field: 'email', header: 'Email', width: 210, editorType: 'text', rejectInvalid: true,\n validate: ({ value }) => (/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(String(value)) ? null : 'That is not an email address') },\n { field: 'age', header: 'Age', width: 90, editorType: 'number', rejectInvalid: true,\n validate: ({ value }) => (Number(value) < 16 || Number(value) > 100 ? 'Age must be between 16 and 100' : null) },\n ]\n</script>\n\n<SvGrid data={rows} {columns} editable containerHeight={200} />\n```\n\nThe inline editor, a commit to a selected range and a full-row edit all\ngo through the check. Paste does not open an editor and is not checked;\na pasted block that must be clean is checked in `onCellValueChange`, as\nbelow.\n\n## Built-in soft validation\n\n`parseEditorValue` already does light validation before either hook runs:\n\n- `number`: rejects non-finite results and stores `null`\n- `date` / `datetime`: rejects unparseable strings and stores `null`\n\nThe cell goes blank rather than red. A `validate` rule that treats `null`\nas invalid turns that into a message.\n\n## Rules over the whole row, and a log of rejections\n\nWhen the rule needs to see the row after the edit, or the app wants a\nrecord of what was refused, check the committed value in\n`onCellValueChange` and put the old one back. The event carries the row\nobject, so the revert is one assignment. Try an age of 200 or an email\nwithout an at-sign:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; email: string; age: number }\n\n let rows = $state<Person[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', age: 36 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', age: 45 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', age: 54 },\n ])\n let rejected = $state<string[]>([])\n\n function check(row: Person): string | null {\n if (row.age < 16 || row.age > 100) return 'Age must be between 16 and 100'\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(row.email)) return 'That is not an email address'\n return null\n }\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 170, editorType: 'text' },\n { field: 'email', header: 'Email', width: 210, editorType: 'text' },\n { field: 'age', header: 'Age', width: 90, editorType: 'number' },\n ]\n</script>\n\n<SvGrid\n data={rows}\n {columns}\n editable\n onCellValueChange={(e) => {\n const problem = check(e.row)\n if (!problem) return\n (e.row as Record<string, unknown>)[e.columnId] = e.oldValue\n rejected = [problem, ...rejected].slice(0, 4)\n }}\n/>\n\n<ul>\n {#each rejected as r}<li>{r}</li>{/each}\n</ul>\n```\n\nThe same pattern with a rollback through `api.setCellValue`, a red flash\nand a \"Recent rejections\" panel:\n\n<div data-docs-demo=\"24-validation\" data-height=\"500\"></div>\n\n## Marking the row instead of reverting\n\nReverting is right when the value is unusable. When it is merely wrong,\nkeeping it and marking the row lets someone fix a form in the order they want\nrather than the order the grid demands.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n import type { ConditionalFormat } from '@svgrid/grid'\n\n let rows = $state<Person[]>(seed.map((p) => ({ ...p })))\n\n const bad = (p: Person) => p.age < 16 || p.age > 100\n\n const problems = $derived(rows.filter(bad).length)\n\n const formats = $derived<ConditionalFormat<Person>[]>([\n { type: 'rule', columns: ['age'], when: (ctx) => bad(ctx.row),\n background: '#fee2e2', color: '#991b1b' },\n ])\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180, editorType: 'text' },\n { field: 'age', header: 'Age', width: 100, editorType: 'number' },\n ]\n</script>\n\n<SvGrid data={rows} {columns} editable conditionalFormats={formats} />\n\n<p aria-live=\"polite\">{problems === 0 ? 'All ages valid.' : problems + ' row(s) out of range.'}</p>\n```\n\n<!-- tutorial:learn-4-editing -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-4-editing\" data-docs-tutorial=\"learn-4-editing\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-4-editing.mp4\" poster=\"/tutorials/learn-4-editing.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 4: editing rows, and validating them, 91 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-4-editing.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-4-editing.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 4: editing rows, and validating them</strong> (91 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>A table people can only read is half a feature. This lesson makes it editable, and then makes it refuse the edits that should not happen.</p>\n<p>The same reactive rows as before. Three people, three fields.</p>\n<p>Each column picks its editor. Text for a name, number for an age. The editor is chosen per column, so the user gets the right control instead of a text box for everything.</p>\n<p>Then validate. It runs as the cell is edited and returns a message when the value is wrong. Pair it with rejectInvalid and the edit is refused outright rather than written and cleaned up later.</p>\n<p>Double-click a cell to edit it, type, and press Enter to commit. That is the whole gesture, and it is the one spreadsheet users already know.</p>\n<p>Now an email that is not one. Validate runs as you type, and hovering the cell tells the user what is wrong. Press Enter and rejectInvalid refuses the commit outright: the value is still the old one. Your row object is never touched by something you would have to clean up later.</p>\n<p>Undo with control zed, save in batches through the grid API, or hand every commit to your server. The editing pages in the docs cover each of those. Lesson five is selection, and doing something with the rows the user picked.</p>\n<p>The file is on the page below and you can edit it there too. See you in lesson five.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-4-editing -->\n\n## See also\n\n- [Parsing values](./parsing-values.md)\n- [Saving values](./saving-values.md)\n- [demos/05-inline-editing.svelte](../../../examples/src/demos/05-inline-editing.svelte)\n"
4448
4455
  },
4449
4456
  {
4450
4457
  "slug": "help/errors",
@@ -4570,7 +4577,7 @@ export const docs = [
4570
4577
  "path": "docs/help/grouping-aggregation.md",
4571
4578
  "title": "Grouping & aggregation",
4572
4579
  "section": "Help",
4573
- "markdown": "# Grouping & aggregation\n\nRoll rows up by one or more columns and compute aggregates (sum, avg,\ncount, min, max, custom) at each group level. Powered by\n`columnGroupingFeature` plus per-column `aggregate` config.\n\n![Flat rows are grouped by one or more fields, aggregated with sum, average or count, then optionally pivoted across rows and columns.](/docs-media/grid-grouping-pivot.svg)\n\nTry it: drag a column into the group-by lane, then change aggregators\nper column:\n\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"500\"></div>\n\n## Minimal example\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, columnGroupingFeature,\n type ColumnDef,\n } from '@svgrid/grid'\n\n type Employee = {\n id: number; name: string; department: string;\n salary: number; performance: number\n }\n\n const features = tableFeatures({ rowSortingFeature, columnGroupingFeature })\n\n const columns: ColumnDef<typeof features, Employee>[] = [\n { field: 'department', header: 'Department' },\n { field: 'name', header: 'Name' },\n { field: 'salary', header: 'Salary',\n aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n { field: 'performance', header: 'Performance',\n aggregate: 'avg' },\n ]\n\n const rows: Employee[] = [\n { id: 1, name: 'Ada', department: 'Engineering', salary: 180_000, performance: 4.8 },\n { id: 2, name: 'Linus', department: 'Engineering', salary: 195_000, performance: 4.6 },\n { id: 3, name: 'Grace', department: 'Operations', salary: 165_000, performance: 4.9 },\n ]\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n groupBy={['department']}\n/>\n```\n\nThe grid emits one group row per unique department value, with the\ngroup cell showing the rolled-up salary sum + average performance.\nClick the chevron on a group row to expand its children.\n\n## Setting the group-by\n\nThree ways, ranked by ergonomic order:\n\n1. **The column menu.** When the user opens a header's menu, \"Group\n by this column\" toggles that column in/out of the group-by list.\n2. **The `groupBy` prop.** Seeds the group-by list and re-applies\n whenever the prop's own value changes, so it works both as initial\n state and as a controlled value. A group-by set from the menu or the\n API is not clobbered while the prop stays put.\n3. **The imperative API.** `api.setGroupBy(['department', 'role'])`\n for toolbars / saved views.\n\n`groupBy` is ignored when `treeData` is set - a row cannot be both a\nhierarchy node and bucketed under a group banner.\n\nThe group order is significant - `['region', 'country']` rolls up\ncountry inside region; reverse the array to flip the hierarchy.\n\n## Built-in aggregators\n\n| Aggregator | Returns | Behaviour on empty groups |\n| ---------- | ---------------------------------------------------- | ------------------------- |\n| `'sum'` | Sum of numeric cell values | `0` |\n| `'avg'` | Arithmetic mean (with safe divide-by-zero) | `null` |\n| `'count'` | Number of leaf rows | `0` |\n| `'min'` | Smallest value (numeric or `Intl.Collator`-comparable) | `null` |\n| `'max'` | Largest value | `null` |\n\n`'sum'` / `'avg'` / `'min'` / `'max'` cast values to `Number`. If the\ncolumn has non-numeric values mixed in, those rows are skipped.\n\n## Custom aggregator\n\nPass a function instead of a string for any group-aware computation:\n\n```ts\n{\n field: 'orders',\n header: 'Top customer',\n aggregate: (rows) => {\n const top = rows.reduce<Employee | null>(\n (acc, r) => !acc || r.orders > acc.orders ? r : acc,\n null,\n )\n return top?.name ?? '-'\n },\n}\n```\n\nThe callback gets every leaf row in the group (already filtered).\nReturn whatever the cell should display - string, number, or a\nformatted value.\n\n## Custom group cell rendering\n\nBy default the group cell shows `key (n)` - e.g. \"Engineering (12)\".\nOverride via the column's `cell` template:\n\n```svelte\n{#snippet GroupCell(props: { row: GroupRow<Employee> })}\n <span class=\"font-semibold\">\n {props.row.groupKey}\n <span class=\"text-sm opacity-60\">({props.row.subRows.length} reports)</span>\n </span>\n{/snippet}\n```\n\nThe `row.groupKey` is the unique group value (the department name in\nthe example). `row.subRows` is the children. `row.depth` is the\nnesting level (useful for indentation when you group by multiple\ncolumns).\n\n## Aggregating string columns\n\nStrings work with `'count'`, `'min'`, `'max'`, and any custom\naggregator. For sum / avg you'll get `NaN` because the cast to\n`Number` fails - the grid renders this as `-` by default.\n\nA useful custom aggregator for strings:\n\n```ts\n{\n field: 'tags',\n aggregate: (rows) => {\n const set = new Set<string>()\n for (const r of rows) for (const t of r.tags) set.add(t)\n return Array.from(set).join(', ')\n },\n}\n```\n\n## Performance\n\nAggregation runs once per group-by change, NOT per scroll frame. The\ncost is O(n) for `count` / `sum` / `avg`, O(n log n) for `min` / `max`\nbecause the engine sorts to find the extreme.\n\nFor a 100k-row dataset grouped by two columns with three aggregators,\nthe pipeline adds ~36 ms to the initial paint (see\n[Performance benchmarks](./benchmarks.md)). After that, scroll is\nunaffected - the renderer hands each visible group its precomputed\nvalue.\n\n## Group expansion state\n\n`expanded` is owned by the engine by default; you can hoist it for\nsaved-views purposes:\n\n```svelte\n<SvGrid\n ...\n expanded={controlledExpanded}\n onExpandedChange={(next) => (controlledExpanded = next)}\n/>\n```\n\n`onExpandedChange` fires for every path that changes expansion: a click\non a group banner, `api.setRowExpanded()`, and\n`api.expandAllGroups()` / `api.collapseAllGroups()`. It receives the\nfull next map, so writing it straight back into `expanded` (as above) is\nsafe and will not loop.\n\nThe shape is `Record<rowId, boolean>`. Group row ids are built from the\ngrouping path rather than the display label - grouping by `department`\ngives `group_department_Engineering`, and adding `role` beneath it gives\n`group_department_Engineering_role_Senior`. Tree rows key off the\nengine's row id instead, so set `getRowId` if you want those keys to be\nyour own ids. Capture the map from `onExpandedChange` rather than\nhand-building the keys.\n\n## Sticky group rows\n\n`stickyGroupRows` keeps the banner of the group a row belongs to (and the\nbanners of the groups above it) under the header while the group's rows\nscroll past, so a screen deep inside a large group still says where it\nis. Under virtualization the band shows a copy of each ancestor banner,\nclickable like the original; without virtualization the banners\nthemselves stick. Off by default; the same prop serves tree data and the\n[server-side row model](./server/server-grouping.md#sticky-group-rows).\n\n## Group sort vs leaf sort\n\nThe sort UI sorts within the active sort scope:\n\n- When grouping is OFF, sort applies to all rows.\n- When grouping is ON, sort applies WITHIN each group - groups stay\n in alphabetical (or group-aggregator) order; only the leaves inside\n each group reorder.\n\nTo sort the groups themselves by their rolled-up value, set the sort\non the aggregated column AFTER setting the group-by. The grid\nrecognises that the column is aggregated and sorts the group rows\ninstead of the leaves.\n\n## Filtering vs grouping\n\nFilters run BEFORE grouping (see [Architecture](./architecture.md) for\nthe pipeline order). The aggregator only sees rows that passed the\nfilter. This is what makes \"department salary sum, filtered to active\nemployees only\" work without any extra config.\n\n## Pivot vs group-by\n\nWhen the question is \"group by row dimensions, also group by column\ndimensions, also pick aggregators per measure\" - that's a pivot. The\n[pivot helpers](./pivot.md) build a different data structure\noptimised for that shape. Use group-by when you only roll up rows;\nuse pivot when you also roll up columns.\n\n## Display modes\n\n`groupDisplayMode` decides where group state is drawn:\n\n| Mode | Result |\n| --- | --- |\n| `groupRows` (default) | A full-width banner row per group. Unchanged behaviour. |\n| `singleColumn` | One synthetic **Group** column holding every level, indented by depth. |\n| `multipleColumns` | One synthetic column per grouped field. |\n\n```svelte\n<SvGrid {data} {columns} groupable groupDisplayMode=\"singleColumn\" />\n```\n\nBoth column modes hide the grouped **source** columns, because their values\nmove into the auto column - showing both would just duplicate them. They also\nrender the group row as an ordinary row, which is the real reason to use them:\nits aggregate cells then line up under the columns they belong to instead of\nsitting in a full-width strip.\n\nTune the combined column with `autoGroupColumnHeader` (default `\"Group\"`) and\n`autoGroupColumnWidth` (default `220`). In `multipleColumns` each column takes\nits name from the source column's header.\n\n## Group footers (subtotal rows)\n\n`groupFooters` closes each group with a subtotal row:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters />\n```\n\nThe footer is a clone of the group banner, so it already carries that group's\naggregates and renders through the normal cell path - each total lands under\nits own column instead of in a full-width strip. It is not expandable and has\nno expander.\n\nOnly columns with an `aggregate` produce a value, the same ones that populate\nthe banner.\n\n## Grand total row\n\n`grandTotalRow` appends a single totals row for the whole filtered set:\n\n```svelte\n<SvGrid {data} {columns} grandTotalRow />\n```\n\nIt is independent of `groupFooters` - use it on a flat grid for a bottom totals\nline, or together for subtotals *and* a total:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters grandTotalRow />\n```\n\nThree things to know:\n\n- It aggregates the **leaf** rows, so turning grouping on does not double-count\n (the group banners already carry subtotals).\n- It follows the **filtered** set, not the raw data - filter the grid and the\n total moves with it.\n- With `pageable`, it is appended only on the **last** page, so a total never\n appears mid-dataset. The value still covers every row, not just that page.\n\nColumns without an `aggregate` render blank, and if no column declares one the\nrow is skipped entirely.\n\n## Grouping with pagination\n\n`pageSize` budgets **data** rows. Group banners and footers do not count\nagainst it:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters pageable pageSize={10} />\n```\n\nA page holds `pageSize` real rows and reprints the banners those rows sit\nunder, so a group split across a page boundary is labelled on both pages - the\nway a spreadsheet repeats group headers across a page break. Footers are\ninserted after paging, so switching them on never pushes data onto the next\npage.\n\nA collapsed group is the visible unit and takes one page slot itself; an\nexpanded one is a header and takes none.\n\n## Frequently asked questions\n\n### How do I group rows in SvGrid?\n\nRegister `columnGroupingFeature` and group by one or more columns. Each group\nrenders a collapsible header row, and you attach an `aggregate` per column to\ncompute sum, avg, count, min, max, or a custom reducer at every group level.\n\n### What aggregation functions does SvGrid support?\n\nBuilt-in `sum`, `avg`, `count`, `min`, and `max`, plus custom aggregators -\nany function that reduces a group's rows to a single value. Aggregates compute\nat each group level and at the grand-total footer.\n\n### Is grouping the same as a pivot table?\n\nNo. Grouping rolls rows up along the row axis. A pivot table also spreads a\nfield across the column axis with nested headers - that is the `@svgrid/enterprise`\npivot model. See [Pivot tables](./pivot.md) for the column-axis version.\n\n## More examples\n\n### Group display modes + footers\n\nSwitch between groupRows banners, a single combined Group column, and one column per grouped field. groupFooters closes each group with a subtotal row under the real columns. Paging counts DATA rows, so pageSize means what it says: a page reprints the banners its rows sit under, and footers never eat the budget.\n\n<div data-docs-demo=\"427-group-footers\" data-height=\"460\"></div>\n\n\n### Group panel (drag & drop)\n\nDevExpress / Kendo-style Group Panel: drag chips into the panel to group, drag inside to reorder grouping levels, × to ungroup. Drives api.setGroupBy() under the hood.\n\n<div data-docs-demo=\"89-group-panel\" data-height=\"460\"></div>\n\n## Try it\n\nGrouping is a prop, and the aggregate shown on each group header comes from the\ncolumn. Add `summary` and the same reducers total the whole filtered set in the\nfooter.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const agg: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80, aggregate: 'avg', summary: 'avg' },\n { field: 'salary', header: 'Salary', width: 130, aggregate: 'sum', summary: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={people} columns={agg} groupBy={['department']} groupable summary sortable />\n```\n\n## Two levels deep\n\n`groupBy` is an array, and the order is the nesting order. Every level gets its\nown aggregate row, computed over the leaf rows beneath it rather than over the\nlevel above.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const nested: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'age', header: 'Age', width: 80, aggregate: 'avg' },\n { field: 'salary', header: 'Salary', width: 130, aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={people} columns={nested} groupBy={['department', 'city']} groupable summary />\n```\n\n## See also\n\n- [Architecture overview](./architecture.md) - where grouping sits in\n the pipeline.\n- [Pivot tables](./pivot.md) - the column-axis version.\n- [Row pagination](./rows/row-pagination.md) - the paging stage runs\n AFTER grouping, so group rows count toward the page size.\n- [Demo #07 Grouping + aggregation](https://svgrid.com/demos/07-grouping-aggregation/)\n - the source for the example above.\n"
4580
+ "markdown": "# Grouping & aggregation\n\nRoll rows up by one or more columns and compute aggregates (sum, avg,\ncount, min, max, custom) at each group level. Powered by\n`columnGroupingFeature` plus per-column `aggregate` config.\n\n![Flat rows are grouped by one or more fields, aggregated with sum, average or count, then optionally pivoted across rows and columns.](/docs-media/grid-grouping-pivot.svg)\n\nTry it: drag a column into the group-by lane, then change aggregators\nper column:\n\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"500\"></div>\n\n## Minimal example\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, columnGroupingFeature,\n type ColumnDef,\n } from '@svgrid/grid'\n\n type Employee = {\n id: number; name: string; department: string;\n salary: number; performance: number\n }\n\n const features = tableFeatures({ rowSortingFeature, columnGroupingFeature })\n\n const columns: ColumnDef<typeof features, Employee>[] = [\n { field: 'department', header: 'Department' },\n { field: 'name', header: 'Name' },\n { field: 'salary', header: 'Salary',\n aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n { field: 'performance', header: 'Performance',\n aggregate: 'avg' },\n ]\n\n const rows: Employee[] = [\n { id: 1, name: 'Ada', department: 'Engineering', salary: 180_000, performance: 4.8 },\n { id: 2, name: 'Linus', department: 'Engineering', salary: 195_000, performance: 4.6 },\n { id: 3, name: 'Grace', department: 'Operations', salary: 165_000, performance: 4.9 },\n ]\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n groupBy={['department']}\n/>\n```\n\nThe grid emits one group row per unique department value, with the\ngroup cell showing the rolled-up salary sum + average performance.\nClick the chevron on a group row to expand its children.\n\n## Setting the group-by\n\nThree ways, ranked by ergonomic order:\n\n1. **The column menu.** When the user opens a header's menu, \"Group\n by this column\" toggles that column in/out of the group-by list.\n2. **The `groupBy` prop.** Seeds the group-by list and re-applies\n whenever the prop's own value changes, so it works both as initial\n state and as a controlled value. A group-by set from the menu or the\n API is not clobbered while the prop stays put.\n3. **The imperative API.** `api.setGroupBy(['department', 'role'])`\n for toolbars / saved views.\n\n`groupBy` is ignored when `treeData` is set - a row cannot be both a\nhierarchy node and bucketed under a group banner.\n\nThe group order is significant - `['region', 'country']` rolls up\ncountry inside region; reverse the array to flip the hierarchy.\n\n## Built-in aggregators\n\n| Aggregator | Returns | Behaviour on empty groups |\n| ---------- | ---------------------------------------------------- | ------------------------- |\n| `'sum'` | Sum of numeric cell values | `0` |\n| `'avg'` | Arithmetic mean (with safe divide-by-zero) | `null` |\n| `'count'` | Number of leaf rows | `0` |\n| `'min'` | Smallest value (numeric or `Intl.Collator`-comparable) | `null` |\n| `'max'` | Largest value | `null` |\n\n`'sum'` / `'avg'` / `'min'` / `'max'` cast values to `Number`. If the\ncolumn has non-numeric values mixed in, those rows are skipped.\n\n## Custom aggregator\n\nPass a function instead of a string for any group-aware computation:\n\n```ts\n{\n field: 'orders',\n header: 'Top customer',\n aggregate: (rows) => {\n const top = rows.reduce<Employee | null>(\n (acc, r) => !acc || r.orders > acc.orders ? r : acc,\n null,\n )\n return top?.name ?? '-'\n },\n}\n```\n\nThe callback gets every leaf row in the group (already filtered).\nReturn whatever the cell should display - string, number, or a\nformatted value.\n\n## Custom group cell rendering\n\nBy default the group cell shows `key (n)` - e.g. \"Engineering (12)\".\nOverride via the column's `cell` template:\n\n```svelte\n{#snippet GroupCell(props: { row: GroupRow<Employee> })}\n <span class=\"font-semibold\">\n {props.row.groupKey}\n <span class=\"text-sm opacity-60\">({props.row.subRows.length} reports)</span>\n </span>\n{/snippet}\n```\n\nThe `row.groupKey` is the unique group value (the department name in\nthe example). `row.subRows` is the children. `row.depth` is the\nnesting level (useful for indentation when you group by multiple\ncolumns).\n\n## Aggregating string columns\n\nStrings work with `'count'`, `'min'`, `'max'`, and any custom\naggregator. For sum / avg you'll get `NaN` because the cast to\n`Number` fails - the grid renders this as `-` by default.\n\nA useful custom aggregator for strings:\n\n```ts\n{\n field: 'tags',\n aggregate: (rows) => {\n const set = new Set<string>()\n for (const r of rows) for (const t of r.tags) set.add(t)\n return Array.from(set).join(', ')\n },\n}\n```\n\n## Performance\n\nAggregation runs once per group-by change, NOT per scroll frame. The\ncost is O(n) for `count` / `sum` / `avg`, O(n log n) for `min` / `max`\nbecause the engine sorts to find the extreme.\n\nFor a 100k-row dataset grouped by two columns with three aggregators,\nthe pipeline adds ~36 ms to the initial paint (see\n[Performance benchmarks](./benchmarks.md)). After that, scroll is\nunaffected - the renderer hands each visible group its precomputed\nvalue.\n\n## Group expansion state\n\n`expanded` is owned by the engine by default; you can hoist it for\nsaved-views purposes:\n\n```svelte\n<SvGrid\n ...\n expanded={controlledExpanded}\n onExpandedChange={(next) => (controlledExpanded = next)}\n/>\n```\n\n`onExpandedChange` fires for every path that changes expansion: a click\non a group banner, `api.setRowExpanded()`, and\n`api.expandAllGroups()` / `api.collapseAllGroups()`. It receives the\nfull next map, so writing it straight back into `expanded` (as above) is\nsafe and will not loop.\n\nThe shape is `Record<rowId, boolean>`. Group row ids are built from the\ngrouping path rather than the display label - grouping by `department`\ngives `group_department_Engineering`, and adding `role` beneath it gives\n`group_department_Engineering_role_Senior`. Tree rows key off the\nengine's row id instead, so set `getRowId` if you want those keys to be\nyour own ids. Capture the map from `onExpandedChange` rather than\nhand-building the keys.\n\n## Sticky group rows\n\n`stickyGroupRows` keeps the banner of the group a row belongs to (and the\nbanners of the groups above it) under the header while the group's rows\nscroll past, so a screen deep inside a large group still says where it\nis. Under virtualization the band shows a copy of each ancestor banner,\nclickable like the original; without virtualization the banners\nthemselves stick. Off by default; the same prop serves tree data and the\n[server-side row model](./server/server-grouping.md#sticky-group-rows).\n\n## Group sort vs leaf sort\n\nThe sort UI sorts within the active sort scope:\n\n- When grouping is OFF, sort applies to all rows.\n- When grouping is ON, sort applies WITHIN each group - groups stay\n in alphabetical (or group-aggregator) order; only the leaves inside\n each group reorder.\n\nTo sort the groups themselves by their rolled-up value, set the sort\non the aggregated column AFTER setting the group-by. The grid\nrecognises that the column is aggregated and sorts the group rows\ninstead of the leaves.\n\n## Filtering vs grouping\n\nFilters run BEFORE grouping (see [Architecture](./architecture.md) for\nthe pipeline order). The aggregator only sees rows that passed the\nfilter. This is what makes \"department salary sum, filtered to active\nemployees only\" work without any extra config.\n\n## Pivot vs group-by\n\nWhen the question is \"group by row dimensions, also group by column\ndimensions, also pick aggregators per measure\" - that's a pivot. The\n[pivot helpers](./pivot.md) build a different data structure\noptimised for that shape. Use group-by when you only roll up rows;\nuse pivot when you also roll up columns.\n\n## Display modes\n\n`groupDisplayMode` decides where group state is drawn:\n\n| Mode | Result |\n| --- | --- |\n| `groupRows` (default) | A full-width banner row per group. Unchanged behaviour. |\n| `singleColumn` | One synthetic **Group** column holding every level, indented by depth. |\n| `multipleColumns` | One synthetic column per grouped field. |\n\n```svelte\n<SvGrid {data} {columns} groupable groupDisplayMode=\"singleColumn\" />\n```\n\nBoth column modes hide the grouped **source** columns, because their values\nmove into the auto column - showing both would just duplicate them. They also\nrender the group row as an ordinary row, which is the real reason to use them:\nits aggregate cells then line up under the columns they belong to instead of\nsitting in a full-width strip.\n\nTune the combined column with `autoGroupColumnHeader` (default `\"Group\"`) and\n`autoGroupColumnWidth` (default `220`). In `multipleColumns` each column takes\nits name from the source column's header.\n\n## Group footers (subtotal rows)\n\n`groupFooters` closes each group with a subtotal row:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters />\n```\n\nThe footer is a clone of the group banner, so it already carries that group's\naggregates and renders through the normal cell path - each total lands under\nits own column instead of in a full-width strip. It is not expandable and has\nno expander.\n\nOnly columns with an `aggregate` produce a value, the same ones that populate\nthe banner.\n\n## Grand total row\n\n`grandTotalRow` appends a single totals row for the whole filtered set:\n\n```svelte\n<SvGrid {data} {columns} grandTotalRow />\n```\n\nIt is independent of `groupFooters` - use it on a flat grid for a bottom totals\nline, or together for subtotals *and* a total:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters grandTotalRow />\n```\n\nThree things to know:\n\n- It aggregates the **leaf** rows, so turning grouping on does not double-count\n (the group banners already carry subtotals).\n- It follows the **filtered** set, not the raw data - filter the grid and the\n total moves with it.\n- With `pageable`, it is appended only on the **last** page, so a total never\n appears mid-dataset. The value still covers every row, not just that page.\n\nColumns without an `aggregate` render blank, and if no column declares one the\nrow is skipped entirely.\n\n## Grouping with pagination\n\n`pageSize` budgets **data** rows. Group banners and footers do not count\nagainst it:\n\n```svelte\n<SvGrid {data} {columns} groupable groupFooters pageable pageSize={10} />\n```\n\nA page holds `pageSize` real rows and reprints the banners those rows sit\nunder, so a group split across a page boundary is labelled on both pages - the\nway a spreadsheet repeats group headers across a page break. Footers are\ninserted after paging, so switching them on never pushes data onto the next\npage.\n\nA collapsed group is the visible unit and takes one page slot itself; an\nexpanded one is a header and takes none.\n\n## Frequently asked questions\n\n### How do I group rows in SvGrid?\n\nRegister `columnGroupingFeature` and group by one or more columns. Each group\nrenders a collapsible header row, and you attach an `aggregate` per column to\ncompute sum, avg, count, min, max, or a custom reducer at every group level.\n\n### What aggregation functions does SvGrid support?\n\nBuilt-in `sum`, `avg`, `count`, `min`, and `max`, plus custom aggregators -\nany function that reduces a group's rows to a single value. Aggregates compute\nat each group level and at the grand-total footer.\n\n### Is grouping the same as a pivot table?\n\nNo. Grouping rolls rows up along the row axis. A pivot table also spreads a\nfield across the column axis with nested headers - that is the `@svgrid/enterprise`\npivot model. See [Pivot tables](./pivot.md) for the column-axis version.\n\n## More examples\n\n### Group display modes + footers\n\nSwitch between groupRows banners, a single combined Group column, and one column per grouped field. groupFooters closes each group with a subtotal row under the real columns. Paging counts DATA rows, so pageSize means what it says: a page reprints the banners its rows sit under, and footers never eat the budget.\n\n<div data-docs-demo=\"427-group-footers\" data-height=\"460\"></div>\n\n\n### Group panel (drag & drop)\n\nDevExpress / Kendo-style Group Panel: drag chips into the panel to group, drag inside to reorder grouping levels, × to ungroup. Drives api.setGroupBy() under the hood.\n\n<div data-docs-demo=\"89-group-panel\" data-height=\"460\"></div>\n\n## Try it\n\nGrouping is a prop, and the aggregate shown on each group header comes from the\ncolumn. Add `summary` and the same reducers total the whole filtered set in the\nfooter.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const agg: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80, aggregate: 'avg', summary: 'avg' },\n { field: 'salary', header: 'Salary', width: 130, aggregate: 'sum', summary: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={people} columns={agg} groupBy={['department']} groupable summary sortable />\n```\n\n## Two levels deep\n\n`groupBy` is an array, and the order is the nesting order. Every level gets its\nown aggregate row, computed over the leaf rows beneath it rather than over the\nlevel above.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const nested: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'age', header: 'Age', width: 80, aggregate: 'avg' },\n { field: 'salary', header: 'Salary', width: 130, aggregate: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={people} columns={nested} groupBy={['department', 'city']} groupable summary />\n```\n\n<!-- tutorial:learn-6-grouping -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-6-grouping\" data-docs-tutorial=\"learn-6-grouping\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-6-grouping.mp4\" poster=\"/tutorials/learn-6-grouping.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 6: grouping and totals, 94 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-6-grouping.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-6-grouping.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 6: grouping and totals</strong> (94 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Once a table has more than a screenful of rows, people stop reading it and start asking it questions. Grouping and totals are how it answers.</p>\n<p>The same people as before, with a department and a city to group by, and a salary worth adding up.</p>\n<p>Two options do the work here. Aggregate says what a group header shows for that column: an average age, a summed salary. Summary does the same thing for the whole table at the bottom. Both read the raw value, so a formatted currency still adds up correctly.</p>\n<p>Then groupBy on the component names the starting grouping, and groupable lets the user change it later.</p>\n<p>Read it back whole: the same people, one column list with the aggregate and summary options on it, and groupBy on the component. That is everything this needs.</p>\n<p>Every department is a group header carrying its own totals, and the row at the bottom totals the table. Open one and the people inside are ordinary rows: still sortable, still editable, still yours.</p>\n<p>Grouping is part of the row model rather than a display trick, which is why the same thing works over server data: the server returns the groups and the grid asks for a group's rows only when someone opens it. Lesson seven makes all of this look like your product.</p>\n<p>The file is on the page below. See you in lesson seven.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-6-grouping -->\n\n## See also\n\n- [Architecture overview](./architecture.md) - where grouping sits in\n the pipeline.\n- [Pivot tables](./pivot.md) - the column-axis version.\n- [Row pagination](./rows/row-pagination.md) - the paging stage runs\n AFTER grouping, so group rows count toward the page size.\n- [Demo #07 Grouping + aggregation](https://svgrid.com/demos/07-grouping-aggregation/)\n - the source for the example above.\n"
4574
4581
  },
4575
4582
  {
4576
4583
  "slug": "help/grouping/aggregators",
@@ -4857,7 +4864,7 @@ export const docs = [
4857
4864
  "path": "docs/help/real-time.md",
4858
4865
  "title": "Real-time / streaming updates",
4859
4866
  "section": "Help",
4860
- "markdown": "# Real-time / streaming updates\n\nHow to drive the grid from a WebSocket / SSE / poll. Four patterns\nranked by the rate of change:\n\n1. **Periodic full refresh** - poll for the latest rows, swap the array.\n2. **Cell flash on change** - the same swap, but the renderer\n highlights cells whose values just changed.\n3. **Delta merge with backlog** - WebSocket pushes individual row\n patches; you merge them into the in-memory state, optionally\n batching while the user has the page paused.\n4. **A large sorted grid under a tick feed** - ticks batched per frame\n into one transaction; the grid repairs the sort around the rows that\n changed instead of re-sorting them all.\n\n![A live WebSocket or SSE feed pushes deltas that apply as add, update, or remove operations, run through the grid as a keyed row transaction, and surface as a cell flash on the changed row.](/docs-media/grid-realtime.svg)\n\nTry a streaming order desk - cell flashes on every change, pause / resume,\ndisconnect / reconnect, configurable throughput slider:\n\n<div data-docs-demo=\"34-realtime-orders\" data-height=\"520\"></div>\n\n## Pattern 1: periodic full refresh\n\nThe simplest reactive pattern. Poll every N seconds, hand the new\narray down.\n\nEvery example below runs against this setup:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature, type ColumnDef } from '@svgrid/grid'\n\n type Order = { id: string; symbol: string; qty: number; price: number; updatedAt: number }\n\n const features = tableFeatures({ rowSortingFeature })\n\n const fmtMoney = (n: number) =>\n n.toLocaleString('en-US', { style: 'currency', currency: 'USD' })\n\n let rows = $state<Order[]>([\n { id: 'o1', symbol: 'AAPL', qty: 120, price: 231.4, updatedAt: Date.now() },\n { id: 'o2', symbol: 'MSFT', qty: 80, price: 418.9, updatedAt: Date.now() },\n { id: 'o3', symbol: 'NVDA', qty: 45, price: 902.1, updatedAt: Date.now() },\n ])\n const data = rows\n\n const columns: ColumnDef<typeof features, Order>[] = [\n { field: 'symbol', header: 'Symbol', width: 110 },\n { field: 'qty', header: 'Qty', width: 90 },\n { field: 'price', header: 'Price', width: 120, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n let rows = $state<Order[]>([])\n\n $effect(() => {\n const id = setInterval(async () => {\n rows = await fetch('/api/orders').then((r) => r.json())\n }, 5_000)\n return () => clearInterval(id)\n })\n</script>\n\n<SvGrid data={rows} columns={columns} features={features} />\n```\n\nThe grid re-renders the visible rows; virtualization keeps the cost\nproportional to the viewport, not the dataset.\n\n**Use when:** dataset is small (< 1000 rows), update rate is low (≥ 5 s),\n\"freshness\" is the only requirement.\n\n**Avoid when:** the user is mid-edit on a cell. A full swap mid-edit\nwill close the editor. Pause your refresh while\n`onActiveCellChange` reports a non-null active cell.\n\n## Pattern 2: cell flash on change\n\nSame swap, but each cell snippet tracks its previous value and renders\na brief highlight when it differs.\n\n```svelte {runnable}\n<script lang=\"ts\">\n // Track per-row, per-field last-seen values.\n let lastSeen = new Map<string, Record<string, unknown>>()\n function diff(rowId: string, current: Record<string, unknown>): Record<string, boolean> {\n const prev = lastSeen.get(rowId)\n const changed: Record<string, boolean> = {}\n if (prev) for (const k of Object.keys(current)) if (prev[k] !== current[k]) changed[k] = true\n lastSeen.set(rowId, { ...current })\n return changed\n }\n</script>\n\n{#snippet PriceCell(props: { row: Order })}\n {@const changes = diff(props.row.id, props.row)}\n <span class={`tabular-nums ${changes.price ? 'flash' : ''}`}>\n {fmtMoney(props.row.price)}\n </span>\n{/snippet}\n```\n\n```css\n.flash {\n animation: flash-bg 800ms ease-out;\n}\n@keyframes flash-bg {\n from { background: rgba(250, 204, 21, 0.4); }\n to { background: transparent; }\n}\n```\n\n**Use when:** user wants to spot changes. The stock-ticker pattern.\n\n**Avoid when:** the flash interferes with selection or accessibility -\nadd `prefers-reduced-motion` guards if the flash is purely\ndecorative.\n\n## Pattern 3: delta merge with backlog\n\nThe grown-up pattern for higher-rate updates. The server pushes\nindividual row patches; you maintain an in-memory map and reassign the\narray when needed.\n\n```svelte {runnable}\n<script lang=\"ts\">\n type OrderId = string\n let rowsMap = $state(new Map<OrderId, Order>())\n let paused = $state(false)\n let backlog = $state<Order[]>([])\n const flushDebounce = 200 // ms\n\n const rows = $derived(Array.from(rowsMap.values()))\n\n $effect(() => {\n const ws = new WebSocket('/api/orders/stream')\n ws.onmessage = (e) => {\n const patch: Order = JSON.parse(e.data)\n if (paused) {\n backlog = [...backlog, patch]\n return\n }\n applyPatch(patch)\n }\n return () => ws.close()\n })\n\n function applyPatch(patch: Order) {\n const next = new Map(rowsMap)\n next.set(patch.id, patch)\n rowsMap = next\n }\n\n function resume() {\n paused = false\n for (const p of backlog) applyPatch(p)\n backlog = []\n }\n</script>\n\n<button onclick={() => (paused = !paused)}>\n {paused ? `Resume (${backlog.length} pending)` : 'Pause'}\n</button>\n\n<SvGrid data={rows} columns={columns} features={features} />\n```\n\nA few production knobs:\n\n- **Debounce the resignment.** A WebSocket spitting out 20 patches per\n second creates 20 array allocations per second. Batch into the next\n `requestAnimationFrame`:\n\n ```ts\n let pendingPatches: Order[] = []\n let scheduled = false\n function applyPatch(patch: Order) {\n pendingPatches.push(patch)\n if (scheduled) return\n scheduled = true\n requestAnimationFrame(() => {\n const next = new Map(rowsMap)\n for (const p of pendingPatches) next.set(p.id, p)\n rowsMap = next\n pendingPatches = []\n scheduled = false\n })\n }\n ```\n\n- **Out-of-order safety.** Each patch carries a server-side sequence\n number; drop patches older than the latest you've applied for that\n row. The streaming demo (#34) shows this with `lastSeq` per row.\n\n- **Disconnect handling.** On `ws.onclose`, set a status flag the user\n sees (\"reconnecting…\") and reconnect with exponential backoff. The\n demo shows the recovery flow.\n\n## Pause during edit / selection\n\nA common mistake: updates fly in while the user is mid-paste or\nmid-edit. The fix is two flags:\n\n```ts\nlet isEditing = $state(false)\nlet hasSelection = $state(false)\nconst isInteracting = $derived(isEditing || hasSelection)\n\n$effect(() => {\n if (isInteracting) {\n paused = true\n } else if (paused) {\n resume()\n }\n})\n```\n\nWire to the grid:\n\n```svelte\n<SvGrid\n ...\n onCellDoubleClick={() => (isEditing = true)}\n onCellValueChange={() => (isEditing = false)}\n onRowSelectionChange={(selection, rows) => (hasSelection = rows.length > 0)}\n/>\n```\n\n`onRowSelectionChange` receives two positional arguments - the\n`{ [rowId]: true }` record and the array of selected rows.\n\nThere is no dedicated editing-state callback today, so the edit flag is\nassembled from the two ends of the edit: a double-click opens the\neditor, and a committed value closes it. That leaves one gap - an edit\nabandoned with `Escape` commits nothing, so clear the flag on the\nwrapper's `onkeydown` too:\n\n```svelte\n<div onkeydown={(e) => { if (e.key === 'Escape') isEditing = false }}>\n <SvGrid ... />\n</div>\n```\n\nIf your app starts edits itself through `api.startEditing()` /\n`api.stopEditing()`, set the flag at those call sites instead - you\nalready know the state there, and it covers every exit path.\n\n## Backpressure (when the server is too fast)\n\nIf your producer can outrun the UI's frame budget, sample down at the\nclient. Drop intermediate patches for the same row; only the newest\none survives:\n\n```ts\nfunction applyPatch(patch: Order) {\n // pendingPatches is keyed by row id so a fast-moving row only\n // commits its NEWEST value per frame.\n pendingPatches.set(patch.id, patch)\n /* ...schedule rAF... */\n}\n```\n\nFor 1000 rows updating at 5 Hz, this caps the work at 1000\nassignments per frame regardless of the actual message rate. The\nstreaming demo's \"throughput slider\" stresses this exact path.\n\n## Pattern 4: a large sorted grid under a tick feed\n\nA blotter is sorted by something that ticks, and it has more rows than the\npatterns above assume. Two facts decide the design.\n\n**Which of the two update paths you take.** Writing a field through a\n`$state` proxy (`rows[i].price = x`) re-renders that one cell and nothing\nelse: the row model caches on the array reference, which did not change, so\nthe sort and the filters are stale for that row until something replaces the\narray. Replacing the array (`api.applyTransaction`, or `rows = next`) runs the\nrow model, and the order is current again. For a sorted blotter the second\npath is the correct one, and the question is how often.\n\n**What a data change costs at that row count.** Every array replacement used\nto re-sort every row - 100,000 rows in about 30 ms in the engine alone, so a\nfeed ticking faster than that could not keep up. The grid now repairs the\nsort instead: when the new array has the same length and only some of its\nobjects were replaced, the sorted stage keeps its previous output, drops the\nreplaced rows, sorts the replacements among themselves and merges them back\nin one pass; a 1,000-row tick on 100,000 sorted rows is about 8 ms in the\nengine, measured on the [benchmarks page](./benchmarks.md#streaming-updates).\nThe filtered stage does the same for a replacement whose membership did not\nchange. The repaired order is exactly the order a full sort would produce.\n\nSo the pattern is: batch ticks per animation frame into one\n`applyTransaction({ update })`, and never mutate a row in place on this\npath. The repair assumes the rows it keeps are unchanged in value as well\nas identity, which an immutable update guarantees. A feed that mutates rows\nin place must pass a new array with no replacements (`rows = [...rows]`) to\nrefresh, and that runs the full pipeline.\n\n```ts\n// Ticks arrive as JSON batches; the newest price per id wins the frame.\nconst pending = new Map<string, number>()\nlet frame: number | null = null\n\nsocket.onmessage = (e) => {\n for (const [id, last] of JSON.parse(e.data).t) pending.set(id, last)\n if (frame === null) frame = requestAnimationFrame(flush)\n}\n\nfunction flush() {\n frame = null\n const byId = new Map(api.getData().map((r) => [r.id, r]))\n const update = []\n for (const [id, last] of pending) {\n const row = byId.get(id)\n if (row) update.push({ ...row, last, direction: last > row.last ? 'up' : 'down' })\n }\n pending.clear()\n api.applyTransaction({ update }) // one data change, one pipeline run\n}\n```\n\nWhen the repair does not apply and the full pipeline runs instead: the tick\nadded or removed rows (structural), it replaced more than a quarter of the\nrows, the sort or filter state changed in the same frame, grouping is\nactive (group rows are rebuilt from the sorted output), or a replaced text\nvalue is one the ranked text sort had never seen. None of these is wrong,\nonly slower, and each is the full-sort cost above.\n\nThe [market blotter demo](https://svgrid.com/demos/495-market-blotter-100k/)\nruns this at 100,000 rows with a live frame-time readout, over an in-page\nsocket or your own (`?ws=`).\n\n## Combining with sort + filter\n\nThe grid's sort + filter run AFTER your patches land in `rows`. Two\nimplications:\n\n- **A row that no longer matches the filter disappears.** Expected.\n- **The active row may move.** When `onActiveCellChange` fires with a\n new index because the row above shifted, your toolbar / detail panel\n should follow.\n\nIf you're using a side detail panel keyed on row id (not index),\nthis is a non-issue - the detail stays bound to the order even as\nthe row moves.\n\n## Frequently asked questions\n\n### How do I show real-time data in SvGrid?\n\nDrive the grid's `data` from a WebSocket, SSE, or poll. This page covers three\npatterns ranked by update rate: periodic full refresh, targeted row patches via\n`getRowId`, and high-frequency cell updates with change-flash highlighting.\n\n### Will the grid keep my scroll and selection on live updates?\n\nYes, if you give rows a stable identity with `getRowId`. Then selection,\nexpansion, edit state, and scroll position survive incoming updates instead of\nresetting on every refresh.\n\n### How fast can SvGrid update?\n\nFast enough for tick-by-tick feeds. The stock-market demo updates 25 symbols\nevery 250 ms with green/red cell flashes while sorting and selection stay\nlive, and the market blotter demo keeps 100,000 rows sorted under tens of\nthousands of updates a second. Batch the ticks per animation frame into one\n`applyTransaction`; the engine cost of a 1,000-row tick on 100,000 sorted\nrows is on the [benchmarks page](./benchmarks.md#streaming-updates).\n\n## See also\n\n- [Server-side data](./server-side-data.md) - the pull side of\n remote data.\n- [Saved views](./saved-views.md) - persist a \"live mode on / off\"\n toggle alongside the rest of the view config.\n- [Performance benchmarks](./benchmarks.md) - measured per-frame cost\n of the patterns above, and the cost of a tick on a sorted grid.\n- [Transactions](./rows/transactions.md) - the `applyTransaction`\n contract and what an update batch costs.\n- [Svelte trading grid](https://svgrid.com/svelte/trading-grid/) - the\n blotter landing page: the 100,000-row demo, the formats and the flash.\n"
4867
+ "markdown": "# Real-time / streaming updates\n\nHow to drive the grid from a WebSocket / SSE / poll. Four patterns\nranked by the rate of change:\n\n1. **Periodic full refresh** - poll for the latest rows, swap the array.\n2. **Cell flash on change** - the same swap, but the renderer\n highlights cells whose values just changed.\n3. **Delta merge with backlog** - WebSocket pushes individual row\n patches; you merge them into the in-memory state, optionally\n batching while the user has the page paused.\n4. **A large sorted grid under a tick feed** - ticks batched per frame\n into one transaction; the grid repairs the sort around the rows that\n changed instead of re-sorting them all.\n\n![A live WebSocket or SSE feed pushes deltas that apply as add, update, or remove operations, run through the grid as a keyed row transaction, and surface as a cell flash on the changed row.](/docs-media/grid-realtime.svg)\n\nTry a streaming order desk - cell flashes on every change, pause / resume,\ndisconnect / reconnect, configurable throughput slider:\n\n<div data-docs-demo=\"34-realtime-orders\" data-height=\"520\"></div>\n\n## Pattern 1: periodic full refresh\n\nThe simplest reactive pattern. Poll every N seconds, hand the new\narray down.\n\nEvery example below runs against this setup:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature, type ColumnDef } from '@svgrid/grid'\n\n type Order = { id: string; symbol: string; qty: number; price: number; updatedAt: number }\n\n const features = tableFeatures({ rowSortingFeature })\n\n const fmtMoney = (n: number) =>\n n.toLocaleString('en-US', { style: 'currency', currency: 'USD' })\n\n let rows = $state<Order[]>([\n { id: 'o1', symbol: 'AAPL', qty: 120, price: 231.4, updatedAt: Date.now() },\n { id: 'o2', symbol: 'MSFT', qty: 80, price: 418.9, updatedAt: Date.now() },\n { id: 'o3', symbol: 'NVDA', qty: 45, price: 902.1, updatedAt: Date.now() },\n ])\n const data = rows\n\n const columns: ColumnDef<typeof features, Order>[] = [\n { field: 'symbol', header: 'Symbol', width: 110 },\n { field: 'qty', header: 'Qty', width: 90 },\n { field: 'price', header: 'Price', width: 120, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n let rows = $state<Order[]>([])\n\n $effect(() => {\n const id = setInterval(async () => {\n rows = await fetch('/api/orders').then((r) => r.json())\n }, 5_000)\n return () => clearInterval(id)\n })\n</script>\n\n<SvGrid data={rows} columns={columns} features={features} />\n```\n\nThe grid re-renders the visible rows; virtualization keeps the cost\nproportional to the viewport, not the dataset.\n\n**Use when:** dataset is small (< 1000 rows), update rate is low (≥ 5 s),\n\"freshness\" is the only requirement.\n\n**Avoid when:** the user is mid-edit on a cell. A full swap mid-edit\nwill close the editor. Pause your refresh while\n`onActiveCellChange` reports a non-null active cell.\n\n## Pattern 2: cell flash on change\n\nSame swap, but each cell snippet tracks its previous value and renders\na brief highlight when it differs.\n\n```svelte {runnable}\n<script lang=\"ts\">\n // Track per-row, per-field last-seen values.\n let lastSeen = new Map<string, Record<string, unknown>>()\n function diff(rowId: string, current: Record<string, unknown>): Record<string, boolean> {\n const prev = lastSeen.get(rowId)\n const changed: Record<string, boolean> = {}\n if (prev) for (const k of Object.keys(current)) if (prev[k] !== current[k]) changed[k] = true\n lastSeen.set(rowId, { ...current })\n return changed\n }\n</script>\n\n{#snippet PriceCell(props: { row: Order })}\n {@const changes = diff(props.row.id, props.row)}\n <span class={`tabular-nums ${changes.price ? 'flash' : ''}`}>\n {fmtMoney(props.row.price)}\n </span>\n{/snippet}\n```\n\n```css\n.flash {\n animation: flash-bg 800ms ease-out;\n}\n@keyframes flash-bg {\n from { background: rgba(250, 204, 21, 0.4); }\n to { background: transparent; }\n}\n```\n\n**Use when:** user wants to spot changes. The stock-ticker pattern.\n\n**Avoid when:** the flash interferes with selection or accessibility -\nadd `prefers-reduced-motion` guards if the flash is purely\ndecorative.\n\n## Pattern 3: delta merge with backlog\n\nThe grown-up pattern for higher-rate updates. The server pushes\nindividual row patches; you maintain an in-memory map and reassign the\narray when needed.\n\n```svelte {runnable}\n<script lang=\"ts\">\n type OrderId = string\n let rowsMap = $state(new Map<OrderId, Order>())\n let paused = $state(false)\n let backlog = $state<Order[]>([])\n const flushDebounce = 200 // ms\n\n const rows = $derived(Array.from(rowsMap.values()))\n\n $effect(() => {\n const ws = new WebSocket('/api/orders/stream')\n ws.onmessage = (e) => {\n const patch: Order = JSON.parse(e.data)\n if (paused) {\n backlog = [...backlog, patch]\n return\n }\n applyPatch(patch)\n }\n return () => ws.close()\n })\n\n function applyPatch(patch: Order) {\n const next = new Map(rowsMap)\n next.set(patch.id, patch)\n rowsMap = next\n }\n\n function resume() {\n paused = false\n for (const p of backlog) applyPatch(p)\n backlog = []\n }\n</script>\n\n<button onclick={() => (paused = !paused)}>\n {paused ? `Resume (${backlog.length} pending)` : 'Pause'}\n</button>\n\n<SvGrid data={rows} columns={columns} features={features} />\n```\n\nA few production knobs:\n\n- **Debounce the resignment.** A WebSocket spitting out 20 patches per\n second creates 20 array allocations per second. Batch into the next\n `requestAnimationFrame`:\n\n ```ts\n let pendingPatches: Order[] = []\n let scheduled = false\n function applyPatch(patch: Order) {\n pendingPatches.push(patch)\n if (scheduled) return\n scheduled = true\n requestAnimationFrame(() => {\n const next = new Map(rowsMap)\n for (const p of pendingPatches) next.set(p.id, p)\n rowsMap = next\n pendingPatches = []\n scheduled = false\n })\n }\n ```\n\n- **Out-of-order safety.** Each patch carries a server-side sequence\n number; drop patches older than the latest you've applied for that\n row. The streaming demo (#34) shows this with `lastSeq` per row.\n\n- **Disconnect handling.** On `ws.onclose`, set a status flag the user\n sees (\"reconnecting…\") and reconnect with exponential backoff. The\n demo shows the recovery flow.\n\n## Pause during edit / selection\n\nA common mistake: updates fly in while the user is mid-paste or\nmid-edit. The fix is two flags:\n\n```ts\nlet isEditing = $state(false)\nlet hasSelection = $state(false)\nconst isInteracting = $derived(isEditing || hasSelection)\n\n$effect(() => {\n if (isInteracting) {\n paused = true\n } else if (paused) {\n resume()\n }\n})\n```\n\nWire to the grid:\n\n```svelte\n<SvGrid\n ...\n onCellDoubleClick={() => (isEditing = true)}\n onCellValueChange={() => (isEditing = false)}\n onRowSelectionChange={(selection, rows) => (hasSelection = rows.length > 0)}\n/>\n```\n\n`onRowSelectionChange` receives two positional arguments - the\n`{ [rowId]: true }` record and the array of selected rows.\n\nThere is no dedicated editing-state callback today, so the edit flag is\nassembled from the two ends of the edit: a double-click opens the\neditor, and a committed value closes it. That leaves one gap - an edit\nabandoned with `Escape` commits nothing, so clear the flag on the\nwrapper's `onkeydown` too:\n\n```svelte\n<div onkeydown={(e) => { if (e.key === 'Escape') isEditing = false }}>\n <SvGrid ... />\n</div>\n```\n\nIf your app starts edits itself through `api.startEditing()` /\n`api.stopEditing()`, set the flag at those call sites instead - you\nalready know the state there, and it covers every exit path.\n\n## Backpressure (when the server is too fast)\n\nIf your producer can outrun the UI's frame budget, sample down at the\nclient. Drop intermediate patches for the same row; only the newest\none survives:\n\n```ts\nfunction applyPatch(patch: Order) {\n // pendingPatches is keyed by row id so a fast-moving row only\n // commits its NEWEST value per frame.\n pendingPatches.set(patch.id, patch)\n /* ...schedule rAF... */\n}\n```\n\nFor 1000 rows updating at 5 Hz, this caps the work at 1000\nassignments per frame regardless of the actual message rate. The\nstreaming demo's \"throughput slider\" stresses this exact path.\n\n## Pattern 4: a large sorted grid under a tick feed\n\nA blotter is sorted by something that ticks, and it has more rows than the\npatterns above assume. Two facts decide the design.\n\n**Which of the two update paths you take.** Writing a field through a\n`$state` proxy (`rows[i].price = x`) re-renders that one cell and nothing\nelse: the row model caches on the array reference, which did not change, so\nthe sort and the filters are stale for that row until something replaces the\narray. Replacing the array (`api.applyTransaction`, or `rows = next`) runs the\nrow model, and the order is current again. For a sorted blotter the second\npath is the correct one, and the question is how often. Adding or removing a\nrow through the proxy (`rows.push(...)`, `rows.splice(...)`) is tracked on its\nown: the row count is a dependency, so the grid rebuilds without you replacing\nthe array. It is the right call for a user clicking \"Add row\" and the wrong one\nfor a tick feed, which changes values rather than the row count.\n\n**What a data change costs at that row count.** Every array replacement used\nto re-sort every row - 100,000 rows in about 30 ms in the engine alone, so a\nfeed ticking faster than that could not keep up. The grid now repairs the\nsort instead: when the new array has the same length and only some of its\nobjects were replaced, the sorted stage keeps its previous output, drops the\nreplaced rows, sorts the replacements among themselves and merges them back\nin one pass; a 1,000-row tick on 100,000 sorted rows is about 8 ms in the\nengine, measured on the [benchmarks page](./benchmarks.md#streaming-updates).\nThe filtered stage does the same for a replacement whose membership did not\nchange. The repaired order is exactly the order a full sort would produce.\n\nSo the pattern is: batch ticks per animation frame into one\n`applyTransaction({ update })`, and never mutate a row in place on this\npath. The repair assumes the rows it keeps are unchanged in value as well\nas identity, which an immutable update guarantees. A feed that mutates rows\nin place must pass a new array with no replacements (`rows = [...rows]`) to\nrefresh, and that runs the full pipeline.\n\n```ts\n// Ticks arrive as JSON batches; the newest price per id wins the frame.\nconst pending = new Map<string, number>()\nlet frame: number | null = null\n\nsocket.onmessage = (e) => {\n for (const [id, last] of JSON.parse(e.data).t) pending.set(id, last)\n if (frame === null) frame = requestAnimationFrame(flush)\n}\n\nfunction flush() {\n frame = null\n const byId = new Map(api.getData().map((r) => [r.id, r]))\n const update = []\n for (const [id, last] of pending) {\n const row = byId.get(id)\n if (row) update.push({ ...row, last, direction: last > row.last ? 'up' : 'down' })\n }\n pending.clear()\n api.applyTransaction({ update }) // one data change, one pipeline run\n}\n```\n\nWhen the repair does not apply and the full pipeline runs instead: the tick\nadded or removed rows (structural), it replaced more than a quarter of the\nrows, the sort or filter state changed in the same frame, grouping is\nactive (group rows are rebuilt from the sorted output), or a replaced text\nvalue is one the ranked text sort had never seen. None of these is wrong,\nonly slower, and each is the full-sort cost above.\n\nThe [market blotter demo](https://svgrid.com/demos/495-market-blotter-100k/)\nruns this at 100,000 rows with a live frame-time readout, over an in-page\nsocket or your own (`?ws=`).\n\n## Combining with sort + filter\n\nThe grid's sort + filter run AFTER your patches land in `rows`. Two\nimplications:\n\n- **A row that no longer matches the filter disappears.** Expected.\n- **The active row may move.** When `onActiveCellChange` fires with a\n new index because the row above shifted, your toolbar / detail panel\n should follow.\n\nIf you're using a side detail panel keyed on row id (not index),\nthis is a non-issue - the detail stays bound to the order even as\nthe row moves.\n\n## Frequently asked questions\n\n### How do I show real-time data in SvGrid?\n\nDrive the grid's `data` from a WebSocket, SSE, or poll. This page covers three\npatterns ranked by update rate: periodic full refresh, targeted row patches via\n`getRowId`, and high-frequency cell updates with change-flash highlighting.\n\n### Will the grid keep my scroll and selection on live updates?\n\nYes, if you give rows a stable identity with `getRowId`. Then selection,\nexpansion, edit state, and scroll position survive incoming updates instead of\nresetting on every refresh.\n\n### How fast can SvGrid update?\n\nFast enough for tick-by-tick feeds. The stock-market demo updates 25 symbols\nevery 250 ms with green/red cell flashes while sorting and selection stay\nlive, and the market blotter demo keeps 100,000 rows sorted under tens of\nthousands of updates a second. Batch the ticks per animation frame into one\n`applyTransaction`; the engine cost of a 1,000-row tick on 100,000 sorted\nrows is on the [benchmarks page](./benchmarks.md#streaming-updates).\n\n## See also\n\n- [Server-side data](./server-side-data.md) - the pull side of\n remote data.\n- [Saved views](./saved-views.md) - persist a \"live mode on / off\"\n toggle alongside the rest of the view config.\n- [Performance benchmarks](./benchmarks.md) - measured per-frame cost\n of the patterns above, and the cost of a tick on a sorted grid.\n- [Transactions](./rows/transactions.md) - the `applyTransaction`\n contract and what an update batch costs.\n- [Svelte trading grid](https://svgrid.com/svelte/trading-grid/) - the\n blotter landing page: the 100,000-row demo, the formats and the flash.\n"
4861
4868
  },
4862
4869
  {
4863
4870
  "slug": "help/recipes",
@@ -4941,7 +4948,7 @@ export const docs = [
4941
4948
  "path": "docs/help/rows/row-sorting.md",
4942
4949
  "title": "Row sorting",
4943
4950
  "section": "Rows",
4944
- "markdown": "# Row sorting\n\nSorting is a feature you opt into. Try it live - click any column\nheader to cycle `none → asc → desc → none`; shift-click adds the\ncolumn to the sort key list (multi-sort):\n\n<div data-docs-demo=\"02-sort-filter-paginate\" data-height=\"440\"></div>\n\n\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const data = people\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, type ColumnDef,\n } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n ]\n</script>\n\n<SvGrid {data} {columns} features={features} />\n```\n\nClicking a sortable header toggles `none → asc → desc → none`. Shift-click\nadds the column to the sort key list (multi-sort).\n\n## Sort functions\n\n`sortFns` exposes the built-in comparators:\n\n```ts\nimport { sortFns } from '@svgrid/grid'\n// sortFns.auto - lexical (default for unknown types)\n// sortFns.number - numeric, NaN-safe\n// sortFns.date - Date-parsed\n```\n\nThe grid picks the comparator based on the column's `editorType`:\n\n| editorType | comparator |\n| ---------- | ---------- |\n| `'number'` | `sortFns.number` |\n| `'date'` \\| `'datetime'` | `sortFns.date` |\n| anything else | `sortFns.auto` |\n\nIf your column has a non-trivial type, set `editorType` even if you do not\nwant inline editing - it is what tells sort and filter how to behave.\n\n## Programmatic sort\n\n```ts\napi.setSort('age', 'desc') // sort by age descending\napi.setSort('age', null) // clear sort on this column\napi.clearSort() // clear all sort\n```\n\n`setSort` replaces any existing sort with a single clause. For\nmulti-column sort, the user clicks the second column header with\n**Shift** held (the in-grid affordance). A multi-sort setter on the API\nis tracked in [Missing features](../missing-features.md).\n\nTo observe sort changes from outside the grid:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onSortingChange={(next) => (sorting = next)}\n/>\n```\n\n## Disable sort per column\n\nNot yet first-class - there is no `enableSorting: false` field. To\nprevent a column from being sortable, omit `rowSortingFeature` from\n`tableFeatures(...)`, or render a custom header via `header: () =>\nrenderSnippet(...)` that doesn't bind to the sort button.\n\n## Sorting + server-side data\n\nWhen sort happens on the backend, pair `externalSort={true}` with\n`onSortingChange` and round-trip the sort clauses through your\nfetcher. See the [`09-server-side` demo](../../../examples/src/demos/09-server-side.svelte).\n\n## Multi-column sort\n\nShift-click a second header and the sort becomes ordered: department first,\nthen salary within it. The order you click is the order applied, which is why\nthe header shows a position number once more than one is active.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n salary: number\n bio: string\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', salary: 142000,\n bio: 'Wrote the first algorithm intended for a machine, and the first account of what a general-purpose computer could do beyond arithmetic.' },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', salary: 168000,\n bio: 'Built the first compiler and argued that programs should be written in something closer to English than to machine code.' },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', salary: 155000,\n bio: 'Wrote a kernel and, later, the version control system that most of the industry now runs on.' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'department', header: 'Department', width: 170 },\n { field: 'name', header: 'Name', width: 180 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={seed} {columns} sortable />\n```\n\n\n## Reading the sort model\n\n`onSortingChange` reports the model as an ordered array, which is exactly the\nshape you persist to a URL or hand to a server.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n salary: number\n bio: string\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', salary: 142000,\n bio: 'Wrote the first algorithm intended for a machine, and the first account of what a general-purpose computer could do beyond arithmetic.' },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', salary: 168000,\n bio: 'Built the first compiler and argued that programs should be written in something closer to English than to machine code.' },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', salary: 155000,\n bio: 'Wrote a kernel and, later, the version control system that most of the industry now runs on.' },\n ]\n\n let model = $state('[]')\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'city', header: 'City', width: 150 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid\n data={seed}\n {columns}\n sortable\n onSortingChange={(s) => (model = JSON.stringify(s))}\n/>\n\n<p>Sort model: <code>{model}</code></p>\n```\n\n## See also\n\n- [Filter API](../filtering/filter-api.md)\n- [Server-side guide](../../getting-started.md#11-server-side-data)\n"
4951
+ "markdown": "# Row sorting\n\nSorting is a feature you opt into. Try it live - click any column\nheader to cycle `none → asc → desc → none`; shift-click adds the\ncolumn to the sort key list (multi-sort):\n\n<div data-docs-demo=\"02-sort-filter-paginate\" data-height=\"440\"></div>\n\n\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n department: string\n age: number\n salary: number\n city: string\n startDate: string\n active: boolean\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', department: 'Engineering', age: 36, salary: 142000, city: 'London', startDate: '2021-03-01', active: true },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', department: 'Engineering', age: 45, salary: 168000, city: 'New York', startDate: '2019-07-15', active: true },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', department: 'Platform', age: 54, salary: 155000, city: 'Portland', startDate: '2020-01-20', active: false },\n { id: 4, name: 'Radia Perlman', email: 'radia@example.com', department: 'Networking', age: 49, salary: 161000, city: 'Seattle', startDate: '2022-09-05', active: true },\n { id: 5, name: 'Barbara Liskov', email: 'barbara@example.com', department: 'Platform', age: 52, salary: 172000, city: 'Boston', startDate: '2018-11-11', active: true },\n ]\n\n const data = people\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, type ColumnDef,\n } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n ]\n</script>\n\n<SvGrid {data} {columns} features={features} />\n```\n\nClicking a sortable header toggles `none → asc → desc → none`. Shift-click\nadds the column to the sort key list (multi-sort).\n\n## Sort functions\n\n`sortFns` exposes the built-in comparators:\n\n```ts\nimport { sortFns } from '@svgrid/grid'\n// sortFns.auto - blanks, then numbers numerically, then text (default for unknown types)\n// sortFns.number - numeric; blanks and non-numeric values ('n/a') first\n// sortFns.date - Date-parsed; blanks and invalid dates first\n```\n\nAll three sort blanks (`null`, `undefined`, `''`) first ascending and last\ndescending, and never return `NaN`, so a column with placeholder text still\nsorts into a consistent order.\n\nThe grid picks the comparator based on the column's `editorType`:\n\n| editorType | comparator |\n| ---------- | ---------- |\n| `'number'` | `sortFns.number` |\n| `'date'` \\| `'datetime'` | `sortFns.date` |\n| anything else | `sortFns.auto` |\n\nIf your column has a non-trivial type, set `editorType` even if you do not\nwant inline editing - it is what tells sort and filter how to behave.\n\n## Programmatic sort\n\n```ts\napi.setSort('age', 'desc') // sort by age descending\napi.setSort('age', null) // clear sort on this column\napi.clearSort() // clear all sort\n```\n\n`setSort` replaces any existing sort with a single clause. For\nmulti-column sort, the user clicks the second column header with\n**Shift** held (the in-grid affordance). A multi-sort setter on the API\nis tracked in [Missing features](../missing-features.md).\n\nTo observe sort changes from outside the grid:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onSortingChange={(next) => (sorting = next)}\n/>\n```\n\n## Disable sort per column\n\nNot yet first-class - there is no `enableSorting: false` field. To\nprevent a column from being sortable, omit `rowSortingFeature` from\n`tableFeatures(...)`, or render a custom header via `header: () =>\nrenderSnippet(...)` that doesn't bind to the sort button.\n\n## Sorting + server-side data\n\nWhen sort happens on the backend, pair `externalSort={true}` with\n`onSortingChange` and round-trip the sort clauses through your\nfetcher. See the [`09-server-side` demo](../../../examples/src/demos/09-server-side.svelte).\n\n## Multi-column sort\n\nShift-click a second header and the sort becomes ordered: department first,\nthen salary within it. The order you click is the order applied, which is why\nthe header shows a position number once more than one is active.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n salary: number\n bio: string\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', salary: 142000,\n bio: 'Wrote the first algorithm intended for a machine, and the first account of what a general-purpose computer could do beyond arithmetic.' },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', salary: 168000,\n bio: 'Built the first compiler and argued that programs should be written in something closer to English than to machine code.' },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', salary: 155000,\n bio: 'Wrote a kernel and, later, the version control system that most of the industry now runs on.' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'department', header: 'Department', width: 170 },\n { field: 'name', header: 'Name', width: 180 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={seed} {columns} sortable />\n```\n\n\n## Reading the sort model\n\n`onSortingChange` reports the model as an ordered array, which is exactly the\nshape you persist to a URL or hand to a server.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n salary: number\n bio: string\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', salary: 142000,\n bio: 'Wrote the first algorithm intended for a machine, and the first account of what a general-purpose computer could do beyond arithmetic.' },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', salary: 168000,\n bio: 'Built the first compiler and argued that programs should be written in something closer to English than to machine code.' },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', salary: 155000,\n bio: 'Wrote a kernel and, later, the version control system that most of the industry now runs on.' },\n ]\n\n let model = $state('[]')\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'city', header: 'City', width: 150 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid\n data={seed}\n {columns}\n sortable\n onSortingChange={(s) => (model = JSON.stringify(s))}\n/>\n\n<p>Sort model: <code>{model}</code></p>\n```\n\n## See also\n\n- [Filter API](../filtering/filter-api.md)\n- [Server-side guide](../../getting-started.md#11-server-side-data)\n"
4945
4952
  },
4946
4953
  {
4947
4954
  "slug": "help/rows/row-spanning",
@@ -4962,7 +4969,7 @@ export const docs = [
4962
4969
  "path": "docs/help/rows/selection-bar.md",
4963
4970
  "title": "Bulk-action bar",
4964
4971
  "section": "Rows",
4965
- "markdown": "# Bulk-action bar\n\nTick some rows and a bar floats over the grid holding the count, the actions\nthat apply to the whole selection, and a way to clear it - the pattern an issue\ntracker uses for bulk edit.\n\n<div data-docs-demo=\"430-selection-bar\" data-height=\"560\"></div>\n\nEvery example below runs against this setup. The bar *renderer* ships in\n`@svgrid/enterprise`, so `enableSelectionBar()` is what lights it up - the\n`selectionBar` prop and its types are part of the free grid. Without the\nrenderer the grid shows a note in the bar's place saying it is an Enterprise\nfeature and pointing at [licensing](https://svgrid.com/pricing/).\n\nLike the rest of Enterprise this comes with the 30-day evaluation period:\nwith the package installed the bar works whether or not a license key is\nset, and without one the grid displays the watermark and the console\nmessage about the Enterprise package. Set a key with `setLicenseKey()`\nbefore shipping.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SelectionBarAction } from '@svgrid/grid'\n import { enableSelectionBar } from '@svgrid/enterprise'\n\n enableSelectionBar()\n\n type Task = {\n id: number\n key: string\n title: string\n assignee: string\n status: 'Open' | 'In progress' | 'Done'\n }\n\n const tasks: Task[] = [\n { id: 1, key: 'PLAT-412', title: 'Virtualize the audit log', assignee: 'A. Osei', status: 'Open' },\n { id: 2, key: 'PLAT-413', title: 'Retry webhook delivery', assignee: 'R. Vance', status: 'In progress' },\n { id: 3, key: 'PLAT-414', title: 'Cache the tenant lookup', assignee: 'M. Iqbal', status: 'Done' },\n { id: 4, key: 'PLAT-415', title: 'Fix timezone drift', assignee: 'J. Lindqvist', status: 'Open' },\n { id: 5, key: 'PLAT-416', title: 'Batch the settings writes', assignee: 'D. Okonkwo', status: 'Done' },\n ]\n\n const columns: GridColumns<Task> = [\n { field: 'key', header: 'Key', width: 110 },\n { field: 'title', header: 'Summary', width: 240 },\n { field: 'assignee', header: 'Assignee', width: 150 },\n { field: 'status', header: 'Status', width: 130 },\n ]\n</script>\n```\n\n## The smallest version\n\n`selectionBar` on its own gives the count and a clear button. Nothing else is\nrequired - `showRowSelection` puts the checkboxes there, and the bar reacts to\nwhatever ends up selected:\n\n```svelte {runnable}\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar />\n```\n\n## Adding actions\n\nAn array is the shorthand for `{ actions }`. Each action gets the whole\nselection as `{ rows, ids }`, in **display order** - so it reads the same order\nthe user is looking at, even after a sort:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let log = $state<string[]>([])\n\n const actions: SelectionBarAction<Task>[] = [\n {\n key: 'done',\n label: 'Mark done',\n icon: 'M20 6 9 17l-5-5',\n action: ({ ids }) => (log = [`Marked ${ids.length} done`, ...log]),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: 'M3 6h18M8 6V4h8v2m-1 0v14H9V6',\n danger: true,\n action: ({ ids }) => (log = [`Deleted ${ids.join(', ')}`, ...log]),\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={actions} />\n\n<ul>\n {#each log as line, i (i)}<li>{line}</li>{/each}\n</ul>\n```\n\n`icon` is the `d` of a single 24x24 SVG path, so an action array stays plain\ndata you can lift into its own module next to the columns.\n\n## Built-in actions\n\nStrings in the array are built-ins, so the common buttons need no wiring:\n\n| Key | Does |\n| --- | --- |\n| `'selectAll'` | Selects every row in the view, then disables itself |\n| `'editFields'` | Opens the bulk-edit drawer - see below |\n| `'separator'` | Draws a divider. Not counted against `maxVisible` |\n\nMix them with your own actions in one array:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const bar: Array<SelectionBarAction<Task> | 'selectAll' | 'editFields' | 'separator'> = [\n 'selectAll',\n 'editFields',\n 'separator',\n { key: 'watch', label: 'Watch', action: () => {} },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={bar} />\n```\n\n## Bulk edit\n\n`'editFields'` opens a **drawer** - the same `SvDrawer` + `SvForm` the grid\nuses for single-row editing - holding one control per editable column. Change\nas many fields as you like and they apply to **every selected row**, as a\nsingle undo run.\n\nThe rule that makes that safe: **only fields you actually change are written.**\n\n| The field | Opens showing | Leave it alone and |\n| --- | --- | --- |\n| Every selected row agrees | That shared value | Nothing is written |\n| They disagree | Blank, labelled \"Multiple values\" | Each row keeps its own value |\n\nSo editing Status across twelve issues does not flatten their summaries to\nwhichever one happened to be first.\n\nThe field list is the grid's own editable, field-backed columns. A column with\n`editable: false` is not offered, because writing it in bulk would be a\nfootgun, and `editorOptions` carry over - a status column with a fixed set\nbecomes a picker in the drawer rather than a free-text box.\n\nWhat it does per cell:\n\n- skips rows whose per-row `editable` rule refuses the write,\n- skips cells already holding the value, so they do not become empty undo steps,\n- fires `onCellValueChange` for every cell it does change,\n- appends the whole run's undo steps together, so `Ctrl+Z` walks the edit back\n rather than needing one press per cell.\n\nThe engine is exported if you want the same write from your own UI:\n\n```ts\nimport { applyBulkEdit, bulkEditableFields, bulkEditInitialValues } from '@svgrid/enterprise'\n\nconst fields = bulkEditableFields(ctrl) // what can be targeted\nconst { values, mixed } = bulkEditInitialValues(ctrl, fields) // what to show\nconst { changed, skipped, fields: n } = applyBulkEdit(ctrl, { status: 'Done', points: 8 })\n```\n\n## On a server-side row model\n\nUnder `<SvGrid rowModel={ctl}>` with the Enterprise row model's selection\nturned on, the bar reads the model instead of the loaded rows: the count\nchip says what the rule selects - a million under select-all, even with\ntwenty rows on screen - and **Edit fields** sends the patch and the rule\nto the datasource's `updateWhere` as one request, rather than writing the\nloaded cells. See [Server selection](../server/server-selection.md).\n\n## Where it floats\n\n`position` pins the bar to the `'bottom'` (default) or `'top'` edge of the\ngrid.\n\nThe bar **floats** - it never resizes the grid, so a layout holding one does\nnot jump when a checkbox is ticked. To stop it hiding anything, the scroll area\ngains bottom padding the height of the bar while a selection is live: the last\nrow can always be scrolled clear, and the bar overlays that padding rather than\na row.\n\n```svelte {runnable}\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ position: 'top', actions: [{ key: 'x', label: 'Export', action: () => {} }] }}\n/>\n```\n\n## Buttons that come and go\n\n`hidden` and `disabled` are re-checked on every render against the live\nselection, so buttons appear and grey out as the set changes rather than\nfreezing at first paint. Select one row and **Merge** is not there; select two\nand it appears:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const conditional: SelectionBarAction<Task>[] = [\n {\n key: 'merge',\n label: 'Merge',\n // Merging one task into itself is meaningless.\n hidden: ({ ids }) => ids.length < 2,\n action: () => {},\n },\n {\n key: 'close',\n label: 'Close',\n // Nothing to do when everything picked is already closed.\n disabled: ({ rows }) => rows.every((t) => t.status === 'Done'),\n action: () => {},\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={conditional} />\n```\n\n## More actions than fit\n\nPast `maxVisible` (6 by default) the tail folds into a `···` menu, which opens\n**away** from the edge the bar is pinned to - upward from a bottom bar, downward\nfrom a top one - so it never falls off the grid. A bar wide enough to scroll is\na bar whose last button nobody finds:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const many: SelectionBarAction<Task>[] = [\n 'Mark done', 'Assign', 'Watch', 'Export', 'Move', 'Archive',\n ].map((label) => ({ key: label, label, action: () => {} }))\n</script>\n\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ actions: many, maxVisible: 3 }}\n/>\n```\n\n## Keyboard and screen readers\n\nThe bar is a `role=\"toolbar\"`, which is a promise about how it behaves, so it\nkeeps that promise:\n\n| Key | Does |\n| --- | --- |\n| `Tab` | One stop for the whole bar, not one per button |\n| `Left` / `Right` | Move between buttons, wrapping at each end |\n| `Esc` | Closes the overflow menu if it is open, otherwise clears the selection |\n\nThese are handled on the bar, so they apply while focus is inside it. `Esc`\npressed in the grid body still does whatever the grid does with it - the bar\ndoes not install a global key handler that would fight cell editing or an open\nmenu.\n\nThe count carries `aria-live=\"polite\"` so it is read as the selection changes.\nThe bar itself is not live - that would re-read every button on each tick.\n\n## Options\n\n| Option | Default | Does |\n| --- | --- | --- |\n| `actions` | `[]` | The buttons, left to right |\n| `position` | `'bottom'` | Which edge the bar floats against |\n| `maxVisible` | `6` | How many stay on the bar before the rest collapse. Separators do not count |\n| `hideClear` | `false` | Drop the trailing clear button |\n\n`selectionBar={true}` is shorthand for all defaults with no actions, and\n`selectionBar={[...]}` for `{ actions: [...] }`.\n\n## Related\n\nDemo `23-bulk-actions` builds the same idea by hand - a toolbar of your own\nmarkup driven by `onRowSelectionChange`. Reach for `selectionBar` when you want\nit working out of the box; hand-roll it when you want full control of the\nlayout.\n\n## See also\n\n- [Row selection](../accessibility.md)\n- [Server selection](../server/server-selection.md) - the bar over a million rows the grid never loaded\n- [Kanban board mode](./kanban-board.md)\n- [Scheduler](./scheduler.md)\n"
4972
+ "markdown": "# Bulk-action bar\n\nTick some rows and a bar floats over the grid holding the count, the actions\nthat apply to the whole selection, and a way to clear it - the pattern an issue\ntracker uses for bulk edit.\n\n<div data-docs-demo=\"430-selection-bar\" data-height=\"560\"></div>\n\nEvery example below runs against this setup. The bar *renderer* ships in\n`@svgrid/enterprise`, so `enableSelectionBar()` is what lights it up - the\n`selectionBar` prop and its types are part of the free grid. Without the\nrenderer the grid shows a note in the bar's place saying it is an Enterprise\nfeature and pointing at [licensing](https://svgrid.com/pricing/).\n\nLike the rest of Enterprise this comes with the 30-day evaluation period:\nwith the package installed the bar works whether or not a license key is\nset, and without one the grid displays the watermark and the console\nmessage about the Enterprise package. Set a key with `setLicenseKey()`\nbefore shipping.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SelectionBarAction } from '@svgrid/grid'\n import { enableSelectionBar } from '@svgrid/enterprise'\n\n enableSelectionBar()\n\n type Task = {\n id: number\n key: string\n title: string\n assignee: string\n status: 'Open' | 'In progress' | 'Done'\n }\n\n const tasks: Task[] = [\n { id: 1, key: 'PLAT-412', title: 'Virtualize the audit log', assignee: 'A. Osei', status: 'Open' },\n { id: 2, key: 'PLAT-413', title: 'Retry webhook delivery', assignee: 'R. Vance', status: 'In progress' },\n { id: 3, key: 'PLAT-414', title: 'Cache the tenant lookup', assignee: 'M. Iqbal', status: 'Done' },\n { id: 4, key: 'PLAT-415', title: 'Fix timezone drift', assignee: 'J. Lindqvist', status: 'Open' },\n { id: 5, key: 'PLAT-416', title: 'Batch the settings writes', assignee: 'D. Okonkwo', status: 'Done' },\n ]\n\n const columns: GridColumns<Task> = [\n { field: 'key', header: 'Key', width: 110 },\n { field: 'title', header: 'Summary', width: 240 },\n { field: 'assignee', header: 'Assignee', width: 150 },\n { field: 'status', header: 'Status', width: 130 },\n ]\n</script>\n```\n\n## The smallest version\n\n`selectionBar` on its own gives the count and a clear button. Nothing else is\nrequired - `showRowSelection` puts the checkboxes there, and the bar reacts to\nwhatever ends up selected:\n\n```svelte {runnable}\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar />\n```\n\n## Adding actions\n\nAn array is the shorthand for `{ actions }`. Each action gets the whole\nselection as `{ rows, ids }`, in **display order** - so it reads the same order\nthe user is looking at, even after a sort:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let log = $state<string[]>([])\n\n const actions: SelectionBarAction<Task>[] = [\n {\n key: 'done',\n label: 'Mark done',\n icon: 'M20 6 9 17l-5-5',\n action: ({ ids }) => (log = [`Marked ${ids.length} done`, ...log]),\n },\n {\n key: 'delete',\n label: 'Delete',\n icon: 'M3 6h18M8 6V4h8v2m-1 0v14H9V6',\n danger: true,\n action: ({ ids }) => (log = [`Deleted ${ids.join(', ')}`, ...log]),\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={actions} />\n\n<ul>\n {#each log as line, i (i)}<li>{line}</li>{/each}\n</ul>\n```\n\n`icon` is the `d` of a single 24x24 SVG path, so an action array stays plain\ndata you can lift into its own module next to the columns.\n\n## Built-in actions\n\nStrings in the array are built-ins, so the common buttons need no wiring:\n\n| Key | Does |\n| --- | --- |\n| `'selectAll'` | Selects every row in the view, then disables itself |\n| `'editFields'` | Opens the bulk-edit drawer - see below |\n| `'separator'` | Draws a divider. Not counted against `maxVisible` |\n\nMix them with your own actions in one array:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const bar: Array<SelectionBarAction<Task> | 'selectAll' | 'editFields' | 'separator'> = [\n 'selectAll',\n 'editFields',\n 'separator',\n { key: 'watch', label: 'Watch', action: () => {} },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={bar} />\n```\n\n## Bulk edit\n\n`'editFields'` opens a **drawer** - the same `SvDrawer` + `SvForm` the grid\nuses for single-row editing - holding one control per editable column. Change\nas many fields as you like and they apply to **every selected row**, as a\nsingle undo run.\n\nThe rule that makes that safe: **only fields you actually change are written.**\n\n| The field | Opens showing | Leave it alone and |\n| --- | --- | --- |\n| Every selected row agrees | That shared value | Nothing is written |\n| They disagree | Blank, labelled \"Multiple values\" | Each row keeps its own value |\n\nSo editing Status across twelve issues does not flatten their summaries to\nwhichever one happened to be first.\n\nThe field list is the grid's own editable, field-backed columns. A column with\n`editable: false` is not offered, because writing it in bulk would be a\nfootgun, and `editorOptions` carry over - a status column with a fixed set\nbecomes a picker in the drawer rather than a free-text box.\n\nWhat it does per cell:\n\n- skips rows whose per-row `editable` rule refuses the write,\n- skips cells already holding the value, so they do not become empty undo steps,\n- fires `onCellValueChange` for every cell it does change,\n- appends the whole run's undo steps together, so `Ctrl+Z` walks the edit back\n rather than needing one press per cell.\n\nThe engine is exported if you want the same write from your own UI:\n\n```ts\nimport { applyBulkEdit, bulkEditableFields, bulkEditInitialValues } from '@svgrid/enterprise'\n\nconst fields = bulkEditableFields(ctrl) // what can be targeted\nconst { values, mixed } = bulkEditInitialValues(ctrl, fields) // what to show\nconst { changed, skipped, fields: n } = applyBulkEdit(ctrl, { status: 'Done', points: 8 })\n```\n\n## On a server-side row model\n\nUnder `<SvGrid rowModel={ctl}>` with the Enterprise row model's selection\nturned on, the bar reads the model instead of the loaded rows: the count\nchip says what the rule selects - a million under select-all, even with\ntwenty rows on screen - and **Edit fields** sends the patch and the rule\nto the datasource's `updateWhere` as one request, rather than writing the\nloaded cells. See [Server selection](../server/server-selection.md).\n\n## Where it floats\n\n`position` pins the bar to the `'bottom'` (default) or `'top'` edge of the\ngrid.\n\nThe bar **floats** - it never resizes the grid, so a layout holding one does\nnot jump when a checkbox is ticked. To stop it hiding anything, the scroll area\ngains bottom padding the height of the bar while a selection is live: the last\nrow can always be scrolled clear, and the bar overlays that padding rather than\na row.\n\n```svelte {runnable}\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ position: 'top', actions: [{ key: 'x', label: 'Export', action: () => {} }] }}\n/>\n```\n\n## Buttons that come and go\n\n`hidden` and `disabled` are re-checked on every render against the live\nselection, so buttons appear and grey out as the set changes rather than\nfreezing at first paint. Select one row and **Merge** is not there; select two\nand it appears:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const conditional: SelectionBarAction<Task>[] = [\n {\n key: 'merge',\n label: 'Merge',\n // Merging one task into itself is meaningless.\n hidden: ({ ids }) => ids.length < 2,\n action: () => {},\n },\n {\n key: 'close',\n label: 'Close',\n // Nothing to do when everything picked is already closed.\n disabled: ({ rows }) => rows.every((t) => t.status === 'Done'),\n action: () => {},\n },\n ]\n</script>\n\n<SvGrid data={tasks} {columns} getRowId={(t) => String(t.id)} showRowSelection selectionBar={conditional} />\n```\n\n## More actions than fit\n\nPast `maxVisible` (6 by default) the tail folds into a `···` menu, which opens\n**away** from the edge the bar is pinned to - upward from a bottom bar, downward\nfrom a top one - so it never falls off the grid. A bar wide enough to scroll is\na bar whose last button nobody finds:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const many: SelectionBarAction<Task>[] = [\n 'Mark done', 'Assign', 'Watch', 'Export', 'Move', 'Archive',\n ].map((label) => ({ key: label, label, action: () => {} }))\n</script>\n\n<SvGrid\n data={tasks}\n {columns}\n getRowId={(t) => String(t.id)}\n showRowSelection\n selectionBar={{ actions: many, maxVisible: 3 }}\n/>\n```\n\n## Keyboard and screen readers\n\nThe bar is a `role=\"toolbar\"`, which is a promise about how it behaves, so it\nkeeps that promise:\n\n| Key | Does |\n| --- | --- |\n| `Tab` | One stop for the whole bar, not one per button |\n| `Left` / `Right` | Move between buttons, wrapping at each end |\n| `Esc` | Closes the overflow menu if it is open, otherwise clears the selection |\n\nThese are handled on the bar, so they apply while focus is inside it. `Esc`\npressed in the grid body still does whatever the grid does with it - the bar\ndoes not install a global key handler that would fight cell editing or an open\nmenu.\n\nThe count carries `aria-live=\"polite\"` so it is read as the selection changes.\nThe bar itself is not live - that would re-read every button on each tick.\n\n## Options\n\n| Option | Default | Does |\n| --- | --- | --- |\n| `actions` | `[]` | The buttons, left to right |\n| `position` | `'bottom'` | Which edge the bar floats against |\n| `maxVisible` | `6` | How many stay on the bar before the rest collapse. Separators do not count |\n| `hideClear` | `false` | Drop the trailing clear button |\n\n`selectionBar={true}` is shorthand for all defaults with no actions, and\n`selectionBar={[...]}` for `{ actions: [...] }`.\n\n## Related\n\nDemo `23-bulk-actions` builds the same idea by hand - a toolbar of your own\nmarkup driven by `onRowSelectionChange`. Reach for `selectionBar` when you want\nit working out of the box; hand-roll it when you want full control of the\nlayout.\n\n<!-- tutorial:learn-5-selection -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-5-selection\" data-docs-tutorial=\"learn-5-selection\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-5-selection.mp4\" poster=\"/tutorials/learn-5-selection.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 5: selection and bulk actions, 86 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-5-selection.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-5-selection.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 5: selection and bulk actions</strong> (86 s, silent).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Selecting rows is easy. Knowing which rows are selected after a sort, a filter or a refresh is the part that catches people out, and it comes down to one prop.</p>\n<p>A small backlog of tasks and the columns to show them.</p>\n<p>Three things on the component. showRowSelection adds the checkbox column. selectionBar floats the action bar. And getRowId is the one that matters: it tells the grid what identifies a row, so a selection survives sorting, filtering and new data arriving. Without it the grid falls back to position, and position changes.</p>\n<p>Tick a couple of rows and the bar appears with a live count and the actions you defined. It stays out of the way until there is something to act on.</p>\n<p>Now sort the table while those rows are ticked. The same three stay selected, because the grid is tracking ids rather than row numbers.</p>\n<p>Over server data the same idea scales: select-all becomes a rule, every row except these, rather than a list of ids the browser would have to hold. Lesson six groups the rows and adds totals.</p>\n<p>The file is on the page below. See you in lesson six.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-5-selection -->\n\n## See also\n\n- [Row selection](../accessibility.md)\n- [Server selection](../server/server-selection.md) - the bar over a million rows the grid never loaded\n- [Kanban board mode](./kanban-board.md)\n- [Scheduler](./scheduler.md)\n"
4966
4973
  },
4967
4974
  {
4968
4975
  "slug": "help/rows/styling-rows",
@@ -5165,7 +5172,7 @@ export const docs = [
5165
5172
  "path": "docs/help/sheet/start.md",
5166
5173
  "title": "Spreadsheet: getting started",
5167
5174
  "section": "Spreadsheet",
5168
- "markdown": "\n# Spreadsheet: getting started\n\nThe one component a spreadsheet needs, how it takes data, how a formula\nreaches another sheet, and the document underneath that a save and a\nreload go through. The other spreadsheet tutorials assume this one, and\n[The spreadsheet shell](../cells/spreadsheet-shell.md) is the reference\nbehind all of them: every prop, every dialog, the file formats, the\nlimits.\n\n`SvSheet` is a spreadsheet, not a mode of the grid: it owns a workbook of\nformulas, address-keyed formats, comments and rules, and draws it with\n`<SvGrid>` underneath. It ships in `@svgrid/enterprise` on top of\n`@svgrid/grid`; `jszip` is a peer you add only when a file is saved or\nopened as `.xlsx` or `.ods`.\n\n```bash\nnpm install @svgrid/grid @svgrid/enterprise\n```\n\nThe examples on this page share one setup: a small budget as raw text,\nand the lookup that formats a rectangle of cells by row and column\nindex.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvSheet, createWorkbook, createSheetDocument, sheetCellsFromRows } from '@svgrid/enterprise'\n\n // Every cell is the text the user would have typed: a number as its\n // digits, a formula starting with `=`, an empty string for nothing.\n const budget: string[][] = [\n ['Line', 'Jan', 'Feb', 'Mar', 'Q1'],\n ['Rent', '2400', '2400', '2400', '=SUM(B2:D2)'],\n ['Payroll', '18500', '18500', '19200', '=SUM(B3:D3)'],\n ['Marketing', '3100', '4800', '2600', '=SUM(B4:D4)'],\n ['Travel', '900', '1350', '400', '=SUM(B5:D5)'],\n ['Total', '=SUM(B2:B5)', '=SUM(C2:C5)', '=SUM(D2:D5)', '=SUM(E2:E5)'],\n ]\n\n // formats.set takes rectangles as [minRow, minCol, maxRow, maxCol],\n // 0-based, and this lookup turns an index into the key the store uses.\n const at = { rowIdAt: (i: number) => `r${i}`, columnIdAt: (i: number) => String.fromCharCode(65 + i) }\n</script>\n```\n\n## The minimum\n\nNo props at all is a working spreadsheet: fifty rows, twelve columns, the\nribbon, the formula bar, the sheet tabs and the status bar. Click a cell\nand type a number, or `=SUM(A1:A5)`, and the Excel keyboard is already\nlive: `Ctrl+Arrow` to the edge of a block, `Ctrl+D` and `Ctrl+R` to fill\ndown and right, `Ctrl+;` for today, `Alt+=` for AutoSum, `Ctrl+Z` for a\nwhole action rather than one cell.\n\n```svelte {runnable}\n<SvSheet />\n```\n\n<div data-docs-demo=\"207-blank-sheet\" data-height=\"520\"></div>\n\n## Seed it with data\n\n`data` takes the sheets to start with, each a name and its cells as raw\ntext, row by row. A ragged row is fine; a missing cell reads as empty.\nNumbers are digits, dates are `yyyy-mm-dd`, and a formula is the text\nthat starts with `=`, exactly as the user would have typed it - the\nsheet works the values out.\n\n```svelte {runnable}\n<SvSheet data={[{ name: 'Budget', cells: budget }]} rows={12} columns={6} />\n```\n\nChange any month and the row's Q1 and the Total row recompute. What the\nsheet keeps is the text, so `getState()` (below) hands back `=SUM(B2:D2)`,\nnot `7200`.\n\n## A formula that reads another sheet\n\nA workbook with more than one sheet is `createWorkbook`, which takes the\nsame list `data` does and gives you the object the formulas run through.\nA reference qualifies the sheet the way Excel writes it: `Prices!B2`, or\n`'Price list'!B2` when the name has a space. `$` pins a row or a column\nwhen the formula is filled down.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const wb = createWorkbook([\n { name: 'Orders', cells: [\n ['Item', 'Qty', 'Unit', 'Amount'],\n ['Desk', '4', '=XLOOKUP(A2,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B2*C2'],\n ['Chair', '12', '=XLOOKUP(A3,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B3*C3'],\n ['Lamp', '6', '=XLOOKUP(A4,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B4*C4'],\n ['Total', '', '', '=SUM(D2:D4)'],\n ] },\n { name: 'Prices', cells: [\n ['Item', 'Unit price'],\n ['Desk', '320'],\n ['Chair', '85'],\n ['Lamp', '40'],\n ] },\n ])\n</script>\n\n<SvSheet workbook={wb} rows={10} columns={6} />\n```\n\nSwitch to the Prices tab, change a price, and switch back: the amounts\nfollowed. `Ctrl+PageUp` and `Ctrl+PageDown` move between sheets from the\nkeyboard, and `Shift+F11` adds one. [Workbooks](../cells/workbooks.md) is\nthe page on sheets that read each other; [Formulas](./formulas.md) is the\npage on what a formula can say.\n\n## A look it opens with\n\nA sheet a person types into can be formatted by hand from the ribbon. A\nsheet you ship should open formatted, or it is a grid of raw numbers\nuntil someone tidies it. `formats` takes cell formats keyed by address\n(`'Orders!F2'` reaches another sheet), and `columnWidths` widths keyed by\nletter:\n\n```svelte {runnable}\n<SvSheet\n data={[{ name: 'Budget', cells: budget }]}\n rows={12}\n columns={6}\n columnWidths={{ A: 130 }}\n formats={{\n A1: { bold: true, fill: '#e2e8f0' }, B1: { bold: true, fill: '#e2e8f0' }, C1: { bold: true, fill: '#e2e8f0' }, D1: { bold: true, fill: '#e2e8f0' }, E1: { bold: true, fill: '#e2e8f0' },\n B2: { numFmt: '#,##0' }, C2: { numFmt: '#,##0' }, D2: { numFmt: '#,##0' }, E2: { numFmt: '#,##0' },\n B3: { numFmt: '#,##0' }, C3: { numFmt: '#,##0' }, D3: { numFmt: '#,##0' }, E3: { numFmt: '#,##0' },\n B4: { numFmt: '#,##0' }, C4: { numFmt: '#,##0' }, D4: { numFmt: '#,##0' }, E4: { numFmt: '#,##0' },\n B5: { numFmt: '#,##0' }, C5: { numFmt: '#,##0' }, D5: { numFmt: '#,##0' }, E5: { numFmt: '#,##0' },\n A6: { bold: true }, B6: { bold: true, numFmt: '#,##0' }, C6: { bold: true, numFmt: '#,##0' }, D6: { bold: true, numFmt: '#,##0' }, E6: { bold: true, numFmt: '#,##0' },\n }}\n/>\n```\n\nOne entry per address gets long by row six, which is what the document\nbelow is for. [Formatting](./formatting.md) covers number formats,\nborders, merged headers and the rules that colour a cell by its value.\n\n## The document\n\nEverything the shell shows beyond the cells lives in a sheet document:\nper sheet its formats, column widths and row heights, hidden lines,\nfrozen panes, comments, protection, page setup, charts, validation and\nconditional formatting rules. `createSheetDocument` builds one around a\nworkbook, `doc.get(name)` is a sheet's part of it, and the `document`\nprop hands the whole thing to the component. `formats.set` formats a\nrectangle at a time, which is how a header row or a money column is one\nline:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const wb = createWorkbook([{ name: 'Budget', cells: budget }])\n const doc = createSheetDocument({ workbook: wb })\n const sheet = doc.get('Budget')\n sheet.formats.set([[0, 0, 0, 4]], { bold: true, fill: '#e2e8f0', color: '#0f172a' }, at)\n sheet.formats.set([[1, 1, 5, 4]], { numFmt: '#,##0' }, at)\n sheet.formats.set([[5, 0, 5, 4]], { bold: true, border: { top: { width: 1 } } }, at)\n sheet.widths.A = 130\n sheet.freeze = { rows: 1, cols: 1 }\n</script>\n\n<SvSheet document={doc} rows={12} columns={6} />\n```\n\nThe header stays put when the sheet scrolls, because `freeze` is part of\nthe document too. So is what the user does next: a format from the\nribbon, a widened column, a new comment all land in `doc`, and\n`doc.getState()` is the whole thing as plain JSON. `onChange` fires once\nper tick with the reasons since the last call, so an autosave is one\nhandler:\n\n```svelte\n<SvSheet document={doc} onChange={(reasons) => save(doc.getState())} />\n```\n\n[Files](./files.md) takes it from there: what the state holds, restoring\nit, `.xlsx` in and out, and the three things a network makes you think\nabout.\n\n## Rows into a sheet\n\nA grid's rows, or an API's, become a sheet with `sheetCellsFromRows`: a\nheader row of labels, a row per record with each value as the text the\nengine reads, and with `totals` a `=SUM` under every column that held a\nnumber. It is how a dataset turns into something the user can add a\nformula to.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const orders = [\n { item: 'Desk', qty: 4, price: 320, shipped: true },\n { item: 'Chair', qty: 12, price: 85, shipped: false },\n { item: 'Lamp', qty: 6, price: 40, shipped: true },\n ]\n const cells = sheetCellsFromRows(orders, [{ field: 'item', label: 'Item' }, 'qty', { field: 'price', label: 'Unit price' }, 'shipped'], { totals: true })\n</script>\n\n<SvSheet data={[{ name: 'Orders', cells }]} rows={10} columns={6} />\n```\n\nA boolean lands as `TRUE` or `FALSE`, a `Date` as `yyyy-mm-dd`, a null as\nnothing.\n\n## The ribbon is Excel's\n\nFile, Home, Insert, Page Layout, Formulas, Data, Review and View, with\nExcel's buttons under them and Excel's shortcuts beside them: `Ctrl+B` and the\nBold button call the same function, and the button lights up because the\ncell is bold, not because it was clicked. Every action reaches an\n`onAction` handler first, so an app can take one over (its own file\npicker on Open, its own dialog on Goal Seek) by returning `true`.\n\n<div data-docs-demo=\"27-spreadsheet-ribbon\" data-height=\"560\"></div>\n\nIn a narrow frame, this page's examples included, a ribbon group that\ndoes not fit folds into one button named for the group, so Home > Cells\n> Format is a click deeper than on a full-width sheet. The `look` prop\npaints the shell with the app's `--sg-*` tokens (`'theme'`, the default)\nor pins Excel's own palette and geometry (`'excel'`). `showRibbon`, `showFormulaBar`, `showTabs` and\n`showStatusBar` hide any part of the chrome, and `height=\"100%\"` fills a\nflex parent.\n\n## See also\n\n- [The spreadsheet shell](../cells/spreadsheet-shell.md) - every prop, every dialog, the file formats and the limits, on one page.\n- [Formulas](./formulas.md) - references, names, LET and LAMBDA, your own functions, auditing.\n- [Formatting](./formatting.md) - number formats, borders, merged headers, conditional formatting.\n- [Files](./files.md) - save, restore, autosave, `.xlsx`, `.ods`, `.xls` and `.csv`.\n- [Excel keyboard shortcuts](../cells/keyboard-shortcuts.md) - the full list, and how to turn them on for a plain grid.\n- [`<sv-sheet>`](../web-components/sv-sheet.md) - the same component as a custom element for React, Vue, Angular or plain HTML.\n"
5175
+ "markdown": "\n# Spreadsheet: getting started\n\nThe one component a spreadsheet needs, how it takes data, how a formula\nreaches another sheet, and the document underneath that a save and a\nreload go through. The other spreadsheet tutorials assume this one, and\n[The spreadsheet shell](../cells/spreadsheet-shell.md) is the reference\nbehind all of them: every prop, every dialog, the file formats, the\nlimits.\n\n`SvSheet` is a spreadsheet, not a mode of the grid: it owns a workbook of\nformulas, address-keyed formats, comments and rules, and draws it with\n`<SvGrid>` underneath. It ships in `@svgrid/enterprise` on top of\n`@svgrid/grid`; `jszip` is a peer you add only when a file is saved or\nopened as `.xlsx` or `.ods`.\n\n```bash\nnpm install @svgrid/grid @svgrid/enterprise\n```\n\n<!-- tutorial:install-spreadsheet -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-install-spreadsheet\" data-docs-tutorial=\"install-spreadsheet\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/install-spreadsheet.mp4\" poster=\"/tutorials/install-spreadsheet.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"Install the SvGrid spreadsheet, 53 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/install-spreadsheet.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/install-spreadsheet.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Install the SvGrid spreadsheet</strong> (53 s, with narration).</figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>The spreadsheet ships in the enterprise package. Install it next to the grid; it runs unlicensed for evaluation.</p>\n<p>A workbook is plain data: sheets with a name and a cells matrix. Formulas are strings that start with an equals sign.</p>\n<p>Hand it to SvSheet. That is the whole API.</p>\n<p>The result is the full surface: a ribbon, the name box and formula bar, lettered columns, sheet tabs and a status bar, with the totals already computed.</p>\n<p>It behaves like a spreadsheet. Type a formula into a cell, press Enter, and the value appears while the formula bar keeps the source.</p>\n<p>Every Excel shortcut, fill handle, number formats, conditional formatting and xlsx import and export come with it. The docs list the whole surface.</p>\n</details>\n</figure>\n<!-- /tutorial:install-spreadsheet -->\n\nThe examples on this page share one setup: a small budget as raw text,\nand the lookup that formats a rectangle of cells by row and column\nindex.\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvSheet, createWorkbook, createSheetDocument, sheetCellsFromRows } from '@svgrid/enterprise'\n\n // Every cell is the text the user would have typed: a number as its\n // digits, a formula starting with `=`, an empty string for nothing.\n const budget: string[][] = [\n ['Line', 'Jan', 'Feb', 'Mar', 'Q1'],\n ['Rent', '2400', '2400', '2400', '=SUM(B2:D2)'],\n ['Payroll', '18500', '18500', '19200', '=SUM(B3:D3)'],\n ['Marketing', '3100', '4800', '2600', '=SUM(B4:D4)'],\n ['Travel', '900', '1350', '400', '=SUM(B5:D5)'],\n ['Total', '=SUM(B2:B5)', '=SUM(C2:C5)', '=SUM(D2:D5)', '=SUM(E2:E5)'],\n ]\n\n // formats.set takes rectangles as [minRow, minCol, maxRow, maxCol],\n // 0-based, and this lookup turns an index into the key the store uses.\n const at = { rowIdAt: (i: number) => `r${i}`, columnIdAt: (i: number) => String.fromCharCode(65 + i) }\n</script>\n```\n\n## The minimum\n\nNo props at all is a working spreadsheet: fifty rows, twelve columns, the\nribbon, the formula bar, the sheet tabs and the status bar. Click a cell\nand type a number, or `=SUM(A1:A5)`, and the Excel keyboard is already\nlive: `Ctrl+Arrow` to the edge of a block, `Ctrl+D` and `Ctrl+R` to fill\ndown and right, `Ctrl+;` for today, `Alt+=` for AutoSum, `Ctrl+Z` for a\nwhole action rather than one cell.\n\n```svelte {runnable}\n<SvSheet />\n```\n\n<div data-docs-demo=\"207-blank-sheet\" data-height=\"520\"></div>\n\n## Seed it with data\n\n`data` takes the sheets to start with, each a name and its cells as raw\ntext, row by row. A ragged row is fine; a missing cell reads as empty.\nNumbers are digits, dates are `yyyy-mm-dd`, and a formula is the text\nthat starts with `=`, exactly as the user would have typed it - the\nsheet works the values out.\n\n```svelte {runnable}\n<SvSheet data={[{ name: 'Budget', cells: budget }]} rows={12} columns={6} />\n```\n\nChange any month and the row's Q1 and the Total row recompute. What the\nsheet keeps is the text, so `getState()` (below) hands back `=SUM(B2:D2)`,\nnot `7200`.\n\n## A formula that reads another sheet\n\nA workbook with more than one sheet is `createWorkbook`, which takes the\nsame list `data` does and gives you the object the formulas run through.\nA reference qualifies the sheet the way Excel writes it: `Prices!B2`, or\n`'Price list'!B2` when the name has a space. `$` pins a row or a column\nwhen the formula is filled down.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const wb = createWorkbook([\n { name: 'Orders', cells: [\n ['Item', 'Qty', 'Unit', 'Amount'],\n ['Desk', '4', '=XLOOKUP(A2,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B2*C2'],\n ['Chair', '12', '=XLOOKUP(A3,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B3*C3'],\n ['Lamp', '6', '=XLOOKUP(A4,Prices!$A$2:$A$4,Prices!$B$2:$B$4)', '=B4*C4'],\n ['Total', '', '', '=SUM(D2:D4)'],\n ] },\n { name: 'Prices', cells: [\n ['Item', 'Unit price'],\n ['Desk', '320'],\n ['Chair', '85'],\n ['Lamp', '40'],\n ] },\n ])\n</script>\n\n<SvSheet workbook={wb} rows={10} columns={6} />\n```\n\nSwitch to the Prices tab, change a price, and switch back: the amounts\nfollowed. `Ctrl+PageUp` and `Ctrl+PageDown` move between sheets from the\nkeyboard, and `Shift+F11` adds one. [Workbooks](../cells/workbooks.md) is\nthe page on sheets that read each other; [Formulas](./formulas.md) is the\npage on what a formula can say.\n\n## A look it opens with\n\nA sheet a person types into can be formatted by hand from the ribbon. A\nsheet you ship should open formatted, or it is a grid of raw numbers\nuntil someone tidies it. `formats` takes cell formats keyed by address\n(`'Orders!F2'` reaches another sheet), and `columnWidths` widths keyed by\nletter:\n\n```svelte {runnable}\n<SvSheet\n data={[{ name: 'Budget', cells: budget }]}\n rows={12}\n columns={6}\n columnWidths={{ A: 130 }}\n formats={{\n A1: { bold: true, fill: '#e2e8f0' }, B1: { bold: true, fill: '#e2e8f0' }, C1: { bold: true, fill: '#e2e8f0' }, D1: { bold: true, fill: '#e2e8f0' }, E1: { bold: true, fill: '#e2e8f0' },\n B2: { numFmt: '#,##0' }, C2: { numFmt: '#,##0' }, D2: { numFmt: '#,##0' }, E2: { numFmt: '#,##0' },\n B3: { numFmt: '#,##0' }, C3: { numFmt: '#,##0' }, D3: { numFmt: '#,##0' }, E3: { numFmt: '#,##0' },\n B4: { numFmt: '#,##0' }, C4: { numFmt: '#,##0' }, D4: { numFmt: '#,##0' }, E4: { numFmt: '#,##0' },\n B5: { numFmt: '#,##0' }, C5: { numFmt: '#,##0' }, D5: { numFmt: '#,##0' }, E5: { numFmt: '#,##0' },\n A6: { bold: true }, B6: { bold: true, numFmt: '#,##0' }, C6: { bold: true, numFmt: '#,##0' }, D6: { bold: true, numFmt: '#,##0' }, E6: { bold: true, numFmt: '#,##0' },\n }}\n/>\n```\n\nOne entry per address gets long by row six, which is what the document\nbelow is for. [Formatting](./formatting.md) covers number formats,\nborders, merged headers and the rules that colour a cell by its value.\n\n## The document\n\nEverything the shell shows beyond the cells lives in a sheet document:\nper sheet its formats, column widths and row heights, hidden lines,\nfrozen panes, comments, protection, page setup, charts, validation and\nconditional formatting rules. `createSheetDocument` builds one around a\nworkbook, `doc.get(name)` is a sheet's part of it, and the `document`\nprop hands the whole thing to the component. `formats.set` formats a\nrectangle at a time, which is how a header row or a money column is one\nline:\n\n```svelte {runnable}\n<script lang=\"ts\">\n const wb = createWorkbook([{ name: 'Budget', cells: budget }])\n const doc = createSheetDocument({ workbook: wb })\n const sheet = doc.get('Budget')\n sheet.formats.set([[0, 0, 0, 4]], { bold: true, fill: '#e2e8f0', color: '#0f172a' }, at)\n sheet.formats.set([[1, 1, 5, 4]], { numFmt: '#,##0' }, at)\n sheet.formats.set([[5, 0, 5, 4]], { bold: true, border: { top: { width: 1 } } }, at)\n sheet.widths.A = 130\n sheet.freeze = { rows: 1, cols: 1 }\n</script>\n\n<SvSheet document={doc} rows={12} columns={6} />\n```\n\nThe header stays put when the sheet scrolls, because `freeze` is part of\nthe document too. So is what the user does next: a format from the\nribbon, a widened column, a new comment all land in `doc`, and\n`doc.getState()` is the whole thing as plain JSON. `onChange` fires once\nper tick with the reasons since the last call, so an autosave is one\nhandler:\n\n```svelte\n<SvSheet document={doc} onChange={(reasons) => save(doc.getState())} />\n```\n\n[Files](./files.md) takes it from there: what the state holds, restoring\nit, `.xlsx` in and out, and the three things a network makes you think\nabout.\n\n## Rows into a sheet\n\nA grid's rows, or an API's, become a sheet with `sheetCellsFromRows`: a\nheader row of labels, a row per record with each value as the text the\nengine reads, and with `totals` a `=SUM` under every column that held a\nnumber. It is how a dataset turns into something the user can add a\nformula to.\n\n```svelte {runnable}\n<script lang=\"ts\">\n const orders = [\n { item: 'Desk', qty: 4, price: 320, shipped: true },\n { item: 'Chair', qty: 12, price: 85, shipped: false },\n { item: 'Lamp', qty: 6, price: 40, shipped: true },\n ]\n const cells = sheetCellsFromRows(orders, [{ field: 'item', label: 'Item' }, 'qty', { field: 'price', label: 'Unit price' }, 'shipped'], { totals: true })\n</script>\n\n<SvSheet data={[{ name: 'Orders', cells }]} rows={10} columns={6} />\n```\n\nA boolean lands as `TRUE` or `FALSE`, a `Date` as `yyyy-mm-dd`, a null as\nnothing.\n\n## The ribbon is Excel's\n\nFile, Home, Insert, Page Layout, Formulas, Data, Review and View, with\nExcel's buttons under them and Excel's shortcuts beside them: `Ctrl+B` and the\nBold button call the same function, and the button lights up because the\ncell is bold, not because it was clicked. Every action reaches an\n`onAction` handler first, so an app can take one over (its own file\npicker on Open, its own dialog on Goal Seek) by returning `true`.\n\n<div data-docs-demo=\"27-spreadsheet-ribbon\" data-height=\"560\"></div>\n\nIn a narrow frame, this page's examples included, a ribbon group that\ndoes not fit folds into one button named for the group, so Home > Cells\n> Format is a click deeper than on a full-width sheet. The `look` prop\npaints the shell with the app's `--sg-*` tokens (`'theme'`, the default)\nor pins Excel's own palette and geometry (`'excel'`). `showRibbon`, `showFormulaBar`, `showTabs` and\n`showStatusBar` hide any part of the chrome, and `height=\"100%\"` fills a\nflex parent.\n\n## See also\n\n- [The spreadsheet shell](../cells/spreadsheet-shell.md) - every prop, every dialog, the file formats and the limits, on one page.\n- [Formulas](./formulas.md) - references, names, LET and LAMBDA, your own functions, auditing.\n- [Formatting](./formatting.md) - number formats, borders, merged headers, conditional formatting.\n- [Files](./files.md) - save, restore, autosave, `.xlsx`, `.ods`, `.xls` and `.csv`.\n- [Excel keyboard shortcuts](../cells/keyboard-shortcuts.md) - the full list, and how to turn them on for a plain grid.\n- [`<sv-sheet>`](../web-components/sv-sheet.md) - the same component as a custom element for React, Vue, Angular or plain HTML.\n"
5169
5176
  },
5170
5177
  {
5171
5178
  "slug": "help/sheet/validation",
@@ -7062,8 +7069,8 @@ export const apiReference = {
7062
7069
  ]
7063
7070
  };
7064
7071
  export const apiSurface = {
7065
- "gridVersion": "3.0.5",
7066
- "enterpriseVersion": "3.0.3",
7072
+ "gridVersion": "3.0.7",
7073
+ "enterpriseVersion": "3.1.1",
7067
7074
  "grid": {
7068
7075
  "values": [
7069
7076
  "ALL_FILTER_OPERATORS",