@svgrid/mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/data.js ADDED
@@ -0,0 +1,575 @@
1
+ // Generated by scripts/build-manifests.mjs. Do not edit by hand.
2
+ /* eslint-disable */
3
+ export const examples = [
4
+ {
5
+ "id": "01-quick-start",
6
+ "path": "examples/src/demos/01-quick-start.svelte",
7
+ "title": "Quick Start",
8
+ "blurb": "01. Quick start A realistic small grid you'd actually surface in an admin tool. Wires up:",
9
+ "source": "<script lang=\"ts\">\n /**\n * 01. Quick start\n * ---------------\n * A realistic small grid you'd actually surface in an admin tool.\n * Wires up:\n * - a row-number column (\"#\")\n * - sortable headers\n * - per-column filter row + the column menu's operator picker\n * - row checkboxes for multi-row selection\n * - cell range selection (click+drag, Shift+arrows)\n * - inline editing (double-click or F2 on any cell)\n * - column resize (drag the right edge of any header)\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n type ColumnDef,\n type SvGridApi,\n } from 'sv-grid-community'\n import { makeOrders, type Order } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n })\n\n let rows = $state<Order[]>(makeOrders(25))\n let api = $state<SvGridApi<typeof features, Order> | null>(null)\n\n // Columns: the leading \"#\" column comes from the grid's built-in\n // showRowNumbers prop, so we don't need to declare an `index` column here.\n // Widths are sized to keep the total under a typical sidebar+padding\n // viewport so the grid doesn't need a horizontal scrollbar.\n const columns: ColumnDef<typeof features, Order>[] = [\n { field: 'company', header: 'Company', editorType: 'text', width: 140 },\n { field: 'product', header: 'Name', editorType: 'text', width: 170 },\n {\n field: 'sellDate',\n header: 'Sell date',\n editorType: 'date',\n width: 110,\n format: { type: 'date', pattern: 'y-m-d' },\n },\n {\n field: 'inStock',\n header: 'In stock',\n editorType: 'checkbox',\n width: 90,\n },\n {\n field: 'quantity',\n header: 'Quantity',\n editorType: 'number',\n width: 90,\n format: { type: 'number', options: { maximumFractionDigits: 0 } },\n },\n { field: 'orderId', header: 'Order ID', editorType: 'text', width: 130 },\n { field: 'country', header: 'Country', editorType: 'text', width: 130 },\n {\n field: 'price',\n header: 'Price',\n editorType: 'number',\n width: 100,\n format: { type: 'currency', currency: 'USD' },\n },\n ]\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"text-sm text-slate-600 dark:text-slate-300 shrink-0\">\n {rows.length} rows · {columns.length} columns ·\n sort, filter, select, edit, and resize columns are all live.\n Double-click a cell or press <kbd>F2</kbd> to edit.\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"both\"\n showRowSelection={true}\n showRowNumbers={true}\n showPagination={false}\n showGroupingControls={false}\n enableInlineEditing={true}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => (api = next)}\n />\n </div>\n\n <footer class=\"text-sm text-slate-500 dark:text-slate-400 shrink-0\">\n Rows: {rows.length}\n </footer>\n</section>\n"
10
+ },
11
+ {
12
+ "id": "02-sort-filter-paginate",
13
+ "path": "examples/src/demos/02-sort-filter-paginate.svelte",
14
+ "title": "Sort Filter Paginate",
15
+ "blurb": "02. Sort · Filter · Paginate Three most-asked-for features wired together against a 5,000-row dataset. - multi-column sort (shift-click headers)",
16
+ "source": "<script lang=\"ts\">\n /**\n * 02. Sort · Filter · Paginate\n * ----------------------------\n * Three most-asked-for features wired together against a 5,000-row dataset.\n * - multi-column sort (shift-click headers)\n * - per-column filter row (text/number)\n * - pagination footer with page-size selector\n * State is owned by this component so it can be persisted (here: in URL).\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n type ColumnDef,\n } from 'sv-grid-community'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n })\n\n const rows = makePeople(5_000)\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'lastName', header: 'Last name', editorType: 'text' },\n { field: 'department', header: 'Department', editorType: 'text' },\n { field: 'country', header: 'Country', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n {\n field: 'salary',\n header: 'Salary',\n editorType: 'number',\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n {\n field: 'joinedAt',\n header: 'Joined',\n editorType: 'date',\n format: { type: 'date', pattern: 'y-m-d' },\n },\n ]\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-3 text-sm shrink-0\">\n <span class=\"text-slate-600 dark:text-slate-300\">{rows.length.toLocaleString()} rows</span>\n <span class=\"text-slate-400\">·</span>\n <span class=\"text-slate-500 dark:text-slate-400\">\n Click a header to sort. Shift+click to multi-sort. Type in the filter row.\n </span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"row\"\n selectionMode=\"none\"\n showPagination={true}\n pageSize={50}\n showGroupingControls={false}\n enableInlineEditing={false}\n enableCellSelection={false}\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n />\n </div>\n</section>\n"
17
+ },
18
+ {
19
+ "id": "03-excel-filters",
20
+ "path": "examples/src/demos/03-excel-filters.svelte",
21
+ "title": "Excel Filters",
22
+ "blurb": "03. Excel-style filters The grid's built-in column menu exposes a per-column operator + value filter - clicking the filter icon on a header opens it. This demo turns",
23
+ "source": "<script lang=\"ts\">\n /**\n * 03. Excel-style filters\n * -----------------------\n * The grid's built-in column menu exposes a per-column operator + value\n * filter - clicking the filter icon on a header opens it. This demo turns\n * that surface on and adds an \"active filter chips\" strip on top, driven\n * by the imperative API.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n type SvGridApi,\n type SvGridFilterOperator,\n } from 'sv-grid-community'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n })\n\n const rows = makePeople(2_500)\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'lastName', header: 'Last name', editorType: 'text' },\n { field: 'department', header: 'Department', editorType: 'text' },\n { field: 'country', header: 'Country', editorType: 'text' },\n { field: 'status', header: 'Status', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n {\n field: 'salary',\n header: 'Salary',\n editorType: 'number',\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n ]\n\n type ActiveChip = { columnId: string; operator: SvGridFilterOperator; value?: string }\n\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n let chips = $state<ActiveChip[]>([])\n\n function applyChip(chip: ActiveChip) {\n chips = [...chips.filter((c) => c.columnId !== chip.columnId), chip]\n api?.setFilter(chip.columnId, { operator: chip.operator, value: chip.value })\n }\n\n function removeChip(columnId: string) {\n chips = chips.filter((c) => c.columnId !== columnId)\n api?.clearFilter(columnId)\n }\n\n function clearAll() {\n for (const chip of chips) api?.clearFilter(chip.columnId)\n chips = []\n }\n\n // Quick presets to demonstrate the API surface.\n function preset(name: 'engineers' | 'senior' | 'active') {\n clearAll()\n if (name === 'engineers') applyChip({ columnId: 'department', operator: 'equals', value: 'Engineering' })\n if (name === 'senior') applyChip({ columnId: 'age', operator: 'greaterThan', value: '50' })\n if (name === 'active') applyChip({ columnId: 'status', operator: 'equals', value: 'active' })\n }\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <span class=\"font-medium\">Quick presets:</span>\n <button onclick={() => preset('engineers')} class=\"rounded border border-slate-300 dark:border-slate-600 px-3 py-1\">Engineering only</button>\n <button onclick={() => preset('senior')} class=\"rounded border border-slate-300 dark:border-slate-600 px-3 py-1\">Age &gt; 50</button>\n <button onclick={() => preset('active')} class=\"rounded border border-slate-300 dark:border-slate-600 px-3 py-1\">Active only</button>\n <span class=\"ml-3 text-slate-500 dark:text-slate-400\">Or click the filter icon on any column header.</span>\n </div>\n\n {#if chips.length}\n <div class=\"flex flex-wrap items-center gap-2 text-xs shrink-0\">\n <span class=\"text-slate-500\">Active:</span>\n {#each chips as chip (chip.columnId)}\n <span class=\"inline-flex items-center gap-1 rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 dark:bg-blue-900 dark:text-blue-200\">\n {chip.columnId} {chip.operator} {chip.value ?? ''}\n <button onclick={() => removeChip(chip.columnId)} aria-label=\"Remove filter\" class=\"rounded-full hover:bg-blue-200 dark:hover:bg-blue-800 px-1\">×</button>\n </span>\n {/each}\n <button onclick={clearAll} class=\"ml-2 text-slate-600 dark:text-slate-300 underline\">Clear all</button>\n </div>\n {/if}\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => (api = next)}\n />\n </div>\n</section>\n"
24
+ },
25
+ {
26
+ "id": "04-selection-copy-paste",
27
+ "path": "examples/src/demos/04-selection-copy-paste.svelte",
28
+ "title": "Selection Copy Paste",
29
+ "blurb": "04. Selection + copy/paste Row checkboxes (selectionMode='both') + cell-range selection. Built-in clipboard:",
30
+ "source": "<script lang=\"ts\">\n /**\n * 04. Selection + copy/paste\n * --------------------------\n * Row checkboxes (selectionMode='both') + cell-range selection.\n * Built-in clipboard:\n * - Ctrl/Cmd+C copies the active rectangular selection as TSV\n * - Ctrl/Cmd+V pastes TSV from the clipboard into the same range\n * The selection summary footer below the grid is rolled by hand -\n * the grid exposes the selected-cell rectangle via the active-cell state.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n rowSelectionFeature,\n type ColumnDef,\n type SvGridApi,\n } from 'sv-grid-community'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n rowSelectionFeature,\n })\n\n const rows = makePeople(80)\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'lastName', header: 'Last name', editorType: 'text' },\n { field: 'department', header: 'Department', editorType: 'text' },\n { field: 'country', header: 'Country', editorType: 'text' },\n {\n field: 'age',\n header: 'Age',\n editorType: 'number',\n },\n {\n field: 'salary',\n header: 'Salary',\n editorType: 'number',\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n {\n field: 'performance',\n header: 'Performance',\n editorType: 'number',\n },\n ]\n\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n let selectedRows = $state<Person[]>([])\n\n const stats = $derived.by(() => {\n let sumSalary = 0\n let perfTotal = 0\n for (const row of selectedRows) {\n sumSalary += row.salary\n perfTotal += row.performance\n }\n return {\n count: selectedRows.length,\n sumSalary,\n avgPerf: selectedRows.length ? Math.round(perfTotal / selectedRows.length) : 0,\n }\n })\n\n const currency = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-3 text-sm shrink-0\">\n <span class=\"text-slate-600 dark:text-slate-300\">\n Tick the row checkboxes, or click + drag cells to make a range selection.\n <kbd>Ctrl/Cmd+C</kbd> copies as TSV.\n </span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"both\"\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => (api = next)}\n onRowSelectionChange={(_, rows) => (selectedRows = rows)}\n />\n </div>\n\n <footer class=\"rounded border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800 px-4 py-2 text-sm flex flex-wrap items-center gap-6 shrink-0\">\n <span><strong>{stats.count}</strong> rows selected</span>\n <span>Σ Salary: <strong>{currency.format(stats.sumSalary)}</strong></span>\n <span>⌀ Performance: <strong>{stats.avgPerf}</strong></span>\n </footer>\n</section>\n"
31
+ },
32
+ {
33
+ "id": "05-inline-editing",
34
+ "path": "examples/src/demos/05-inline-editing.svelte",
35
+ "title": "Inline Editing",
36
+ "blurb": "05. Inline editing Typed editors per column. Edits are tracked locally (dirty markers) and applied on \"Save\". `enableInlineEditing` enables double-click / F2 to edit;",
37
+ "source": "<script lang=\"ts\">\n /**\n * 05. Inline editing\n * ------------------\n * Typed editors per column. Edits are tracked locally (dirty markers) and\n * applied on \"Save\". `enableInlineEditing` enables double-click / F2 to edit;\n * the wrapper exposes the cell-edit life-cycle through the API.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n type SvGridApi,\n } from 'sv-grid-community'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n })\n\n let rows = $state<Person[]>(makePeople(50))\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n let dirty = $state<Record<string, true>>({})\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'lastName', header: 'Last name', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'department', header: 'Department', editorType: 'text' },\n {\n field: 'salary',\n header: 'Salary',\n editorType: 'number',\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n {\n field: 'joinedAt',\n header: 'Joined',\n editorType: 'date',\n format: { type: 'date', pattern: 'y-m-d' },\n },\n { field: 'active', header: 'Active', editorType: 'checkbox' },\n ]\n\n // svelte-ignore state_referenced_locally\n let initial = rows.map((r) => ({ ...r }))\n\n function reset() {\n rows = initial.map((r) => ({ ...r }))\n dirty = {}\n }\n\n function save() {\n initial = rows.map((r) => ({ ...r }))\n dirty = {}\n // In a real app: persist `rows` to the server here.\n }\n\n // The wrapper applies edits to its internal copy. We mirror them into `rows`\n // by reading through the imperative API after each store change.\n let lastSyncedSerialized = ''\n $effect(() => {\n if (!api) return\n const snapshot = api.getData() as ReadonlyArray<Person>\n const serialized = JSON.stringify(snapshot)\n if (serialized === lastSyncedSerialized) return\n lastSyncedSerialized = serialized\n // Diff against `initial` to compute dirty cells.\n const next: Record<string, true> = {}\n for (let i = 0; i < snapshot.length; i++) {\n const a = snapshot[i]!\n const b = initial[i]\n if (!b) continue\n for (const key of Object.keys(a) as Array<keyof Person>) {\n if (a[key] !== b[key]) next[`${a.id}.${key}`] = true\n }\n }\n dirty = next\n rows = snapshot.map((r) => ({ ...r }))\n })\n\n const dirtyCount = $derived(Object.keys(dirty).length)\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-3 text-sm shrink-0\">\n <span class=\"text-slate-600 dark:text-slate-300\">\n Double-click a cell, or press <kbd>F2</kbd>, to edit. <kbd>Enter</kbd> commits, <kbd>Esc</kbd> cancels.\n </span>\n <span class=\"ml-auto flex items-center gap-2\">\n <span class=\"text-slate-500 dark:text-slate-400\">{dirtyCount} edited cells</span>\n <button\n type=\"button\"\n onclick={reset}\n disabled={dirtyCount === 0}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-3 py-1 disabled:opacity-50\"\n >Reset</button>\n <button\n type=\"button\"\n onclick={save}\n disabled={dirtyCount === 0}\n class=\"rounded bg-blue-600 text-white px-3 py-1 disabled:opacity-50\"\n >Save changes</button>\n </span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showPagination={false}\n enableInlineEditing={true}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => (api = next)}\n />\n </div>\n</section>\n"
38
+ },
39
+ {
40
+ "id": "06-large-dataset",
41
+ "path": "examples/src/demos/06-large-dataset.svelte",
42
+ "title": "Large Dataset",
43
+ "blurb": "06. Large dataset, virtualized Row + column virtualization make a wide grid scroll smoothly. The user can scale the dataset up at runtime. The default is 10,000 rows",
44
+ "source": "<script lang=\"ts\">\n /**\n * 06. Large dataset, virtualized\n * ------------------------------\n * Row + column virtualization make a wide grid scroll smoothly.\n *\n * The user can scale the dataset up at runtime. The default is 10,000 rows\n * × 50 columns - a realistic enterprise size that mounts in well under a\n * second. The 100,000-row option pushes the grid hard; expect a brief\n * pause on mount, then smooth scrolling once the virtualizer is live.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n } from 'sv-grid-community'\n import { makeWidePeople, type WidePerson } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n })\n\n type Size = { rows: number; cols: number; label: string }\n const sizes: Size[] = [\n { rows: 1_000, cols: 25, label: '1k × 25' },\n { rows: 10_000, cols: 50, label: '10k × 50' },\n { rows: 50_000, cols: 75, label: '50k × 75' },\n { rows: 100_000, cols: 95, label: '100k × 100' },\n ]\n\n let size = $state<Size>(sizes[1]!)\n let busy = $state(false)\n let rows = $state.raw<WidePerson[]>([])\n let columns = $state.raw<ColumnDef<typeof features, WidePerson>[]>([])\n let mountedAt = $state(0)\n\n function buildColumns(metrics: number): ColumnDef<typeof features, WidePerson>[] {\n const W = 180\n const base: ColumnDef<typeof features, WidePerson>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text', width: W },\n { field: 'lastName', header: 'Last name', editorType: 'text', width: W },\n { field: 'department', header: 'Department', editorType: 'text', width: W },\n { field: 'country', header: 'Country', editorType: 'text', width: W },\n { field: 'status', header: 'Status', editorType: 'text', width: W },\n ]\n const metric: ColumnDef<typeof features, WidePerson>[] = []\n for (let i = 0; i < metrics; i++) {\n metric.push({\n field: `metric_${i}` as `metric_${number}`,\n header: `Metric ${i}`,\n editorType: 'number',\n format: { type: 'number', options: { maximumFractionDigits: 2 } },\n width: W,\n })\n }\n return [...base, ...metric]\n }\n\n async function load(next: Size) {\n busy = true\n // Unmount the grid first so the heavy old rows are GC'd before the new ones\n // are generated. Without this, peak memory is ~2× the larger size.\n rows = []\n columns = []\n await new Promise((r) => requestAnimationFrame(r))\n const t0 = performance.now()\n const generated = makeWidePeople(next.rows, next.cols, 1337)\n columns = buildColumns(next.cols)\n rows = generated\n size = next\n mountedAt = Math.round(performance.now() - t0)\n busy = false\n }\n\n // Initial load\n $effect(() => {\n if (rows.length === 0 && !busy) load(size)\n })\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <span class=\"font-medium\">Dataset:</span>\n {#each sizes as option (option.label)}\n {@const active = option.rows === size.rows && option.cols === size.cols}\n <button\n type=\"button\"\n onclick={() => load(option)}\n disabled={busy || active}\n class=\"rounded border px-3 py-1 {active ? 'bg-slate-200 dark:bg-slate-700 font-semibold' : 'border-slate-300 dark:border-slate-600'} disabled:opacity-50\"\n >{option.label}</button>\n {/each}\n <span class=\"ml-auto text-slate-500 dark:text-slate-400\">\n {#if busy}\n Generating…\n {:else if rows.length}\n {size.rows.toLocaleString()} rows · {size.cols + 5} columns · generated in {mountedAt} ms\n {/if}\n </span>\n </div>\n\n {#if rows.length}\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={false}\n showRowNumbers={true}\n virtualization={true}\n columnVirtualization={true}\n rowHeight={32}\n overscan={8}\n columnOverscan={3}\n columnWidth={180}\n containerHeight=\"100%\"\n />\n </div>\n {/if}\n</section>\n"
45
+ },
46
+ {
47
+ "id": "07-grouping-aggregation",
48
+ "path": "examples/src/demos/07-grouping-aggregation.svelte",
49
+ "title": "Grouping Aggregation",
50
+ "blurb": "07. Grouping + aggregation The grid's built-in grouping pipeline buckets rows by one or more columns and renders a group row in their place. Aggregation here is",
51
+ "source": "<script lang=\"ts\">\n /**\n * 07. Grouping + aggregation\n * --------------------------\n * The grid's built-in grouping pipeline buckets rows by one or more\n * columns and renders a group row in their place. Aggregation here is\n * computed in the demo (the engine resolves shared values per group; this\n * component layers sum/avg on top for the visible \"Salary\" and\n * \"Performance\" columns via the row-summary footer).\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n type ColumnDef,\n type SvGridApi,\n } from 'sv-grid-community'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n const rows = makePeople(500)\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'department', header: 'Department', editorType: 'text' },\n { field: 'country', header: 'Country', editorType: 'text' },\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'lastName', header: 'Last name', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n {\n field: 'salary',\n header: 'Salary',\n editorType: 'number',\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n { field: 'performance', header: 'Performance', editorType: 'number' },\n ]\n\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n let groupBy = $state<string[]>(['department'])\n\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <span class=\"font-medium\">Group by:</span>\n <button\n onclick={() => applyGroup([])}\n class=\"rounded border px-3 py-1 {groupBy.length === 0 ? 'bg-slate-200 dark:bg-slate-700' : 'border-slate-300 dark:border-slate-600'}\"\n >None</button>\n <button\n onclick={() => applyGroup(['department'])}\n class=\"rounded border px-3 py-1 {groupBy.join() === 'department' ? 'bg-slate-200 dark:bg-slate-700' : 'border-slate-300 dark:border-slate-600'}\"\n >Department</button>\n <button\n onclick={() => applyGroup(['country'])}\n class=\"rounded border px-3 py-1 {groupBy.join() === 'country' ? 'bg-slate-200 dark:bg-slate-700' : 'border-slate-300 dark:border-slate-600'}\"\n >Country</button>\n <button\n onclick={() => applyGroup(['department', 'country'])}\n class=\"rounded border px-3 py-1 {groupBy.join() === 'department,country' ? 'bg-slate-200 dark:bg-slate-700' : 'border-slate-300 dark:border-slate-600'}\"\n >Department → Country</button>\n <span class=\"ml-3 text-slate-500 dark:text-slate-400\">\n Click a group row to expand. The row-summaries footer aggregates totals.\n </span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showPagination={false}\n showGroupingControls={true}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => {\n api = next\n // Apply the initial group-by once the imperative API is available.\n next.setGroupBy(groupBy)\n }}\n />\n </div>\n</section>\n"
52
+ },
53
+ {
54
+ "id": "08-tree-and-master-detail",
55
+ "path": "examples/src/demos/08-tree-and-master-detail.svelte",
56
+ "title": "Tree And Master Detail",
57
+ "blurb": "08. Tree data + master/detail The community build does not (yet) ship a dedicated tree-data row model, so this demo flattens a synthetic file tree by hand and indents the",
58
+ "source": "<script lang=\"ts\">\n /**\n * 08. Tree data + master/detail\n * -----------------------------\n * The community build does not (yet) ship a dedicated tree-data row model,\n * so this demo flattens a synthetic file tree by hand and indents the\n * \"name\" column based on the row's depth. Expansion is toggled via the\n * row-expanding feature.\n *\n * The lower grid demonstrates master/detail by mounting a second\n * `<SvGrid>` instance keyed to the selected master row.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n rowExpandingFeature,\n renderSnippet,\n type ColumnDef,\n } from 'sv-grid-community'\n\n type FsNode = {\n id: string\n name: string\n kind: 'folder' | 'file'\n size: number\n modified: string\n depth: number\n childIds: string[]\n parentId: string | null\n }\n\n function makeFs(): FsNode[] {\n const out: FsNode[] = []\n const push = (n: Omit<FsNode, 'childIds'> & { childIds?: string[] }) =>\n out.push({ childIds: [], ...n })\n\n push({ id: 'root', parentId: null, depth: 0, name: 'project', kind: 'folder', size: 0, modified: '2026-05-01' })\n push({ id: 'src', parentId: 'root', depth: 1, name: 'src', kind: 'folder', size: 0, modified: '2026-05-12' })\n push({ id: 'src/index.ts', parentId: 'src', depth: 2, name: 'index.ts', kind: 'file', size: 482, modified: '2026-05-12' })\n push({ id: 'src/core.ts', parentId: 'src', depth: 2, name: 'core.ts', kind: 'file', size: 12_310, modified: '2026-05-12' })\n push({ id: 'src/ui', parentId: 'src', depth: 2, name: 'ui', kind: 'folder', size: 0, modified: '2026-05-09' })\n push({ id: 'src/ui/Grid.svelte', parentId: 'src/ui', depth: 3, name: 'Grid.svelte', kind: 'file', size: 3_410, modified: '2026-05-09' })\n push({ id: 'src/ui/theme.css', parentId: 'src/ui', depth: 3, name: 'theme.css', kind: 'file', size: 1_204, modified: '2026-05-09' })\n push({ id: 'tests', parentId: 'root', depth: 1, name: 'tests', kind: 'folder', size: 0, modified: '2026-05-04' })\n push({ id: 'tests/grid.test.ts', parentId: 'tests', depth: 2, name: 'grid.test.ts', kind: 'file', size: 5_602, modified: '2026-05-04' })\n push({ id: 'tests/a11y.test.ts', parentId: 'tests', depth: 2, name: 'a11y.test.ts', kind: 'file', size: 2_201, modified: '2026-05-04' })\n push({ id: 'package.json', parentId: 'root', depth: 1, name: 'package.json', kind: 'file', size: 612, modified: '2026-05-01' })\n push({ id: 'README.md', parentId: 'root', depth: 1, name: 'README.md', kind: 'file', size: 1_802, modified: '2026-05-01' })\n\n // populate childIds\n const byId = new Map(out.map((n) => [n.id, n]))\n for (const n of out) if (n.parentId) byId.get(n.parentId)!.childIds.push(n.id)\n return out\n }\n\n const featuresFs = tableFeatures({\n rowSortingFeature,\n rowExpandingFeature,\n })\n\n const allNodes = makeFs()\n let expanded = $state<Record<string, boolean>>({ root: true, src: true })\n // Sort clauses owned by the demo, not the grid: the grid runs in\n // `externalSort` mode so it doesn't flatten the hierarchy. Single-column\n // sort is plenty for a tree.\n let sortState = $state<Array<{ id: string; desc: boolean }>>([])\n\n function visible(): FsNode[] {\n const out: FsNode[] = []\n const byId = new Map(allNodes.map((n) => [n.id, n]))\n\n // Sort children WITHIN each parent - keeps every node next to its\n // ancestors regardless of sort direction.\n const clause = sortState[0]\n const cmp = clause\n ? (a: FsNode, b: FsNode) => {\n const av = (a as unknown as Record<string, unknown>)[clause.id]\n const bv = (b as unknown as Record<string, unknown>)[clause.id]\n let r: number\n if (typeof av === 'number' && typeof bv === 'number') r = av - bv\n else r = String(av ?? '').localeCompare(String(bv ?? ''), undefined, { numeric: true })\n return clause.desc ? -r : r\n }\n : null\n\n const walk = (id: string) => {\n const n = byId.get(id)\n if (!n) return\n out.push(n)\n if (!expanded[id]) return\n const childIds = cmp\n ? [...n.childIds].sort((a, b) => cmp(byId.get(a)!, byId.get(b)!))\n : n.childIds\n for (const child of childIds) walk(child)\n }\n walk('root')\n return out\n }\n\n const fsRows = $derived(visible())\n\n function toggle(id: string) {\n expanded = { ...expanded, [id]: !expanded[id] }\n }\n\n // Keyboard expand/collapse: capture-phase keydown listener on the tree\n // grid's wrapper. When the active cell is in the \"name\" column and its\n // row is a folder, ArrowRight expands, ArrowLeft collapses, Enter/Space\n // toggles. We use capture so we see the event before the grid's built-in\n // arrow navigation moves the active cell.\n let treeContainerEl = $state<HTMLDivElement | null>(null)\n $effect(() => {\n if (!treeContainerEl) return\n const container = treeContainerEl\n function onKey(event: KeyboardEvent) {\n if (\n event.key !== 'ArrowLeft' &&\n event.key !== 'ArrowRight' &&\n event.key !== 'Enter' &&\n event.key !== ' '\n ) {\n return\n }\n const active = container.querySelector<HTMLElement>('.sv-grid-cell-active')\n if (!active) return\n if (active.getAttribute('data-col-id') !== 'name') return\n const rowIdx = Number(active.getAttribute('data-svgrid-row'))\n if (!Number.isFinite(rowIdx)) return\n const node = fsRows[rowIdx]\n if (!node) return\n const isFolder = node.kind === 'folder' && node.childIds.length > 0\n if (!isFolder) return\n const isExpanded = !!expanded[node.id]\n if (event.key === 'ArrowRight' && !isExpanded) {\n toggle(node.id)\n } else if (event.key === 'ArrowLeft' && isExpanded) {\n toggle(node.id)\n } else if (event.key === 'Enter' || event.key === ' ') {\n toggle(node.id)\n } else {\n return // let the grid handle (e.g. ArrowRight while already expanded)\n }\n event.preventDefault()\n event.stopPropagation()\n }\n container.addEventListener('keydown', onKey, { capture: true })\n return () => container.removeEventListener('keydown', onKey, { capture: true })\n })\n\n // --- master / detail ---\n type Order = { id: string; customer: string; date: string; total: number }\n type Line = { sku: string; name: string; qty: number; unit: number }\n\n const orders: Order[] = [\n { id: 'O-1001', customer: 'Acme', date: '2026-05-10', total: 1284.50 },\n { id: 'O-1002', customer: 'Globex', date: '2026-05-11', total: 312.00 },\n { id: 'O-1003', customer: 'Initech', date: '2026-05-12', total: 5_812.20 },\n ]\n const lines: Record<string, Line[]> = {\n 'O-1001': [\n { sku: 'A-1', name: 'Widget', qty: 4, unit: 12.00 },\n { sku: 'B-7', name: 'Sprocket', qty: 2, unit: 25.50 },\n { sku: 'C-3', name: 'Gizmo Premium', qty: 1, unit: 1_186.50 },\n ],\n 'O-1002': [\n { sku: 'A-1', name: 'Widget', qty: 26, unit: 12.00 },\n ],\n 'O-1003': [\n { sku: 'D-2', name: 'Machined frame', qty: 1, unit: 4_812.20 },\n { sku: 'E-9', name: 'Service plan', qty: 1, unit: 1_000.00 },\n ],\n }\n\n let selectedOrder = $state<string>('O-1001')\n\n const featuresOrders = tableFeatures({ rowSortingFeature })\n\n const orderColumns: ColumnDef<typeof featuresOrders, Order>[] = [\n { field: 'id', header: 'Order' },\n { field: 'customer', header: 'Customer' },\n { field: 'date', header: 'Date', format: { type: 'date', pattern: 'y-m-d' } },\n {\n field: 'total',\n header: 'Total',\n format: { type: 'currency', currency: 'USD' },\n },\n ]\n\n const lineColumns: ColumnDef<typeof featuresOrders, Line>[] = [\n { field: 'sku', header: 'SKU' },\n { field: 'name', header: 'Item' },\n { field: 'qty', header: 'Qty' },\n { field: 'unit', header: 'Unit', format: { type: 'currency', currency: 'USD' } },\n ]\n\n const detailRows = $derived(lines[selectedOrder] ?? [])\n\n // fsColumns is built lazily so it can reference the TreeName snippet\n // declared in markup below (snippet declarations hoist).\n const fsColumns: ColumnDef<typeof featuresFs, FsNode>[] = (() => [\n {\n id: 'name',\n header: 'Name',\n accessorFn: (row) => row.name,\n cell: (ctx) => renderSnippet(TreeName, { node: ctx.row.original }),\n width: 320,\n },\n { field: 'kind', header: 'Kind' },\n {\n field: 'size',\n header: 'Size',\n editorType: 'number',\n format: { type: 'number', options: { maximumFractionDigits: 0 } },\n },\n {\n field: 'modified',\n header: 'Modified',\n format: { type: 'date', pattern: 'y-m-d' },\n },\n ])()\n</script>\n\n{#snippet TreeName(props: { node: FsNode })}\n {@const canExpand = props.node.kind === 'folder' && props.node.childIds.length > 0}\n <span style=\"padding-left: {props.node.depth * 16}px;\" class=\"inline-flex items-center gap-1\">\n {#if canExpand}\n <button\n type=\"button\"\n onclick={() => toggle(props.node.id)}\n class=\"inline-flex h-4 w-4 items-center justify-center rounded hover:bg-slate-200 dark:hover:bg-slate-700\"\n aria-label=\"Toggle\"\n aria-expanded={!!expanded[props.node.id]}\n >{expanded[props.node.id] ? '▾' : '▸'}</button>\n {:else}\n <span class=\"inline-block h-4 w-4\"></span>\n {/if}\n <span aria-hidden=\"true\">{props.node.kind === 'folder' ? '📁' : '📄'}</span>\n <span>{props.node.name}</span>\n </span>\n{/snippet}\n\n<section class=\"space-y-6\">\n <div>\n <h3 class=\"mb-2 font-semibold\">Tree data - file system</h3>\n <p class=\"mb-2 text-xs text-slate-500 dark:text-slate-400\">\n Click a row, then use <kbd>→</kbd>/<kbd>←</kbd> to expand/collapse,\n or <kbd>Enter</kbd>/<kbd>Space</kbd> to toggle.\n </p>\n <div bind:this={treeContainerEl}>\n <SvGrid\n data={fsRows}\n columns={fsColumns}\n features={featuresFs}\n filterMode=\"none\"\n selectionMode=\"cell\"\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={false}\n enableRowSummaries={false}\n rowHeight={32}\n containerHeight={380}\n fitColumns={true}\n externalSort={true}\n onSortingChange={(next) => (sortState = next)}\n />\n </div>\n </div>\n\n <div>\n <h3 class=\"mb-2 font-semibold\">Master / detail - orders → line items</h3>\n <p class=\"mb-2 text-sm text-slate-500 dark:text-slate-400\">Click an order to load its lines below.</p>\n <div class=\"grid gap-4 md:grid-cols-2\">\n <div>\n <h4 class=\"mb-1 text-xs uppercase tracking-wide text-slate-500\">Orders</h4>\n <ul class=\"divide-y divide-slate-200 dark:divide-slate-700 rounded border border-slate-200 dark:border-slate-700\">\n {#each orders as o (o.id)}\n <li>\n <button\n type=\"button\"\n onclick={() => (selectedOrder = o.id)}\n class=\"w-full text-left px-3 py-2 text-sm hover:bg-slate-50 dark:hover:bg-slate-800 {selectedOrder === o.id ? 'bg-blue-50 dark:bg-blue-900/40' : ''}\"\n >\n <span class=\"font-medium\">{o.id}</span>\n <span class=\"text-slate-500 dark:text-slate-400\"> · {o.customer} · {o.date}</span>\n </button>\n </li>\n {/each}\n </ul>\n </div>\n <div>\n <h4 class=\"mb-1 text-xs uppercase tracking-wide text-slate-500\">Line items - {selectedOrder}</h4>\n {#key selectedOrder}\n <SvGrid\n data={detailRows}\n columns={lineColumns}\n features={featuresOrders}\n filterMode=\"none\"\n selectionMode=\"none\"\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={false}\n enableRowSummaries={false}\n rowHeight={32}\n containerHeight={220}\n />\n {/key}\n </div>\n </div>\n </div>\n</section>\n"
59
+ },
60
+ {
61
+ "id": "09-server-side",
62
+ "path": "examples/src/demos/09-server-side.svelte",
63
+ "title": "Server Side",
64
+ "blurb": "09. Server-side data Sort, filter, and page are pushed to a mock \"server\" (an async function over a large seeded dataset). Only the visible page is held in memory.",
65
+ "source": "<script lang=\"ts\">\n /**\n * 09. Server-side data\n * --------------------\n * Sort, filter, and page are pushed to a mock \"server\" (an async function\n * over a large seeded dataset). Only the visible page is held in memory.\n * The dev-loop pattern:\n * 1. owning state is in this component\n * 2. an effect debounces (250 ms) and turns state into a query\n * 3. an AbortController cancels stale requests\n *\n * The grid runs in `externalSort` + `externalFilter` mode so its built-in\n * sort/filter UI only updates the *query state* - the actual fetch goes\n * back to the endpoint, which sees the full 100k-row dataset.\n *\n * Replace `mockEndpoint` with `fetch('/api/people?...')` and the rest of\n * the structure stays the same.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n type SvGridApi,\n } from 'sv-grid-community'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n })\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'lastName', header: 'Last name', editorType: 'text' },\n { field: 'department', header: 'Department', editorType: 'text' },\n { field: 'country', header: 'Country', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n {\n field: 'salary',\n header: 'Salary',\n editorType: 'number',\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n ]\n\n // The \"remote\" dataset. Held in module scope so the demo doesn't regenerate it on every key press.\n const ALL = makePeople(100_000)\n\n type SortClause = { id: string; desc: boolean }\n type GridFilter = {\n id: string\n operator: string\n value: string\n selectedValues?: Array<string>\n }\n type Query = {\n q: string\n department: string\n page: number\n pageSize: number\n sort: SortClause[]\n gridFilters: GridFilter[]\n }\n\n let q = $state('')\n let dept = $state('')\n let page = $state(0)\n const pageSize = 25\n let loading = $state(false)\n let total = $state(0)\n let rows = $state<Person[]>([])\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n let sortClauses = $state<SortClause[]>([])\n let gridFilters = $state<GridFilter[]>([])\n\n function getField(person: Person, id: string): unknown {\n return (person as unknown as Record<string, unknown>)[id]\n }\n\n function matchesGridFilter(person: Person, filter: GridFilter): boolean {\n const raw = getField(person, filter.id)\n if (filter.selectedValues && filter.selectedValues.length) {\n if (!filter.selectedValues.includes(String(raw ?? ''))) return false\n }\n const op = filter.operator\n const v = filter.value\n if (!v && op !== 'isBlank') return true\n const text = String(raw ?? '').toLowerCase()\n const needle = v.toLowerCase()\n switch (op) {\n case 'contains': return text.includes(needle)\n case 'equals': return text === needle\n case 'startsWith': return text.startsWith(needle)\n case 'greaterThan': return Number(raw) > Number(v)\n case 'lessThan': return Number(raw) < Number(v)\n case 'isBlank': return raw === null || raw === undefined || String(raw) === ''\n default: return true\n }\n }\n\n // Faked \"network\" latency. Kept just long enough to show a \"Loading…\" flash\n // on slow connections without making the demo feel laggy. Real apps obviously\n // get whatever the wire gives them.\n const NETWORK_LATENCY_MS = 60\n\n async function mockEndpoint(query: Query, signal: AbortSignal): Promise<{ rows: Person[]; total: number }> {\n await new Promise<void>((resolve, reject) => {\n const t = setTimeout(resolve, NETWORK_LATENCY_MS)\n signal.addEventListener('abort', () => {\n clearTimeout(t)\n reject(new DOMException('aborted', 'AbortError'))\n })\n })\n let matches = ALL.filter((p) => {\n if (query.q && !`${p.firstName} ${p.lastName} ${p.email}`.toLowerCase().includes(query.q.toLowerCase())) return false\n if (query.department && p.department !== query.department) return false\n for (const f of query.gridFilters) if (!matchesGridFilter(p, f)) return false\n return true\n })\n if (query.sort.length) {\n // Raw `<` / `>` is ~10x faster than Intl.Collator over 100k rows and\n // good enough for a demo. A real server would push this to the DB.\n matches = [...matches].sort((a, b) => {\n for (const clause of query.sort) {\n const av = getField(a, clause.id)\n const bv = getField(b, clause.id)\n let r: number\n if (typeof av === 'number' && typeof bv === 'number') r = av - bv\n else {\n const as = String(av ?? '')\n const bs = String(bv ?? '')\n r = as < bs ? -1 : as > bs ? 1 : 0\n }\n if (r !== 0) return clause.desc ? -r : r\n }\n return 0\n })\n }\n const start = query.page * query.pageSize\n return { rows: matches.slice(start, start + query.pageSize), total: matches.length }\n }\n\n let controller: AbortController | null = null\n let debounceTimer: ReturnType<typeof setTimeout> | null = null\n\n function runQuery() {\n controller?.abort()\n controller = new AbortController()\n const signal = controller.signal\n loading = true\n mockEndpoint(\n { q, department: dept, page, pageSize, sort: sortClauses, gridFilters },\n signal,\n )\n .then((res) => {\n if (signal.aborted) return\n rows = res.rows\n total = res.total\n loading = false\n })\n .catch((err) => {\n if ((err as Error).name !== 'AbortError') {\n console.error(err)\n loading = false\n }\n })\n }\n\n // Click-driven inputs (page, department dropdown, column sort) fire\n // immediately - a click should never wait on a debounce timer.\n $effect(() => {\n page; dept; sortClauses\n runQuery()\n })\n\n // Typed inputs (search box, in-grid column filter value) are debounced so\n // we don't hammer the \"server\" on every keystroke.\n $effect(() => {\n q; gridFilters\n if (debounceTimer) clearTimeout(debounceTimer)\n debounceTimer = setTimeout(runQuery, 120)\n return () => {\n if (debounceTimer) clearTimeout(debounceTimer)\n }\n })\n\n const pageCount = $derived(Math.max(1, Math.ceil(total / pageSize)))\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-end gap-3 text-sm shrink-0\">\n <label class=\"flex flex-col\">\n <span class=\"text-slate-500 dark:text-slate-400\">Search</span>\n <input\n type=\"text\"\n bind:value={q}\n oninput={() => (page = 0)}\n placeholder=\"name or email\"\n class=\"rounded border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1 w-56\"\n />\n </label>\n <label class=\"flex flex-col\">\n <span class=\"text-slate-500 dark:text-slate-400\">Department</span>\n <select\n bind:value={dept}\n onchange={() => (page = 0)}\n class=\"rounded border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1 w-48\"\n >\n <option value=\"\">All</option>\n <option>Engineering</option>\n <option>Design</option>\n <option>Product</option>\n <option>Sales</option>\n <option>Support</option>\n <option>Operations</option>\n </select>\n </label>\n <span class=\"ml-auto text-slate-500 dark:text-slate-400\">\n {#if loading}<span aria-live=\"polite\">Loading…</span>{:else}{total.toLocaleString()} matches{/if}\n </span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n externalSort={true}\n externalFilter={true}\n onSortingChange={(next) => {\n sortClauses = next\n page = 0\n }}\n onFiltersChange={(next) => {\n gridFilters = next.columns\n page = 0\n }}\n onApiReady={(next) => (api = next)}\n />\n </div>\n\n <nav class=\"flex items-center justify-between text-sm shrink-0\">\n <button\n type=\"button\"\n onclick={() => (page = Math.max(0, page - 1))}\n disabled={page === 0 || loading}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-3 py-1 disabled:opacity-50\"\n >‹ Previous</button>\n <span class=\"text-slate-500 dark:text-slate-400\">Page {page + 1} of {pageCount.toLocaleString()}</span>\n <button\n type=\"button\"\n onclick={() => (page = Math.min(pageCount - 1, page + 1))}\n disabled={page + 1 >= pageCount || loading}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-3 py-1 disabled:opacity-50\"\n >Next ›</button>\n </nav>\n</section>\n"
66
+ },
67
+ {
68
+ "id": "10-custom-cells-and-themes",
69
+ "path": "examples/src/demos/10-custom-cells-and-themes.svelte",
70
+ "title": "Custom Cells And Themes",
71
+ "blurb": "10. Custom cells + themes Demonstrates `renderSnippet` for custom cell content, a density toggle driven entirely by CSS custom properties, and a forced light/dark/",
72
+ "source": "<script lang=\"ts\">\n /**\n * 10. Custom cells + themes\n * -------------------------\n * Demonstrates `renderSnippet` for custom cell content, a density toggle\n * driven entirely by CSS custom properties, and a forced light/dark/\n * high-contrast theme switch. ARIA roles & focus styles come from the\n * grid's built-in a11y helpers - they do not need to be re-declared here.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n renderSnippet,\n type ColumnDef,\n } from 'sv-grid-community'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({ rowSortingFeature })\n\n const rows = makePeople(50)\n\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\n let theme = $state<'auto' | 'light' | 'dark' | 'high-contrast'>('auto')\n\n // Complete palettes per theme. The previous version only overrode --sg-bg\n // and --sg-fg, so the zebra rows / headers / borders kept the surrounding\n // page's dark values and the grid ended up as light-and-dark stripes.\n // A theme is either *every* relevant token or none (auto = inherit page).\n type Palette = Record<string, string>\n const THEME_PALETTES: Record<'light' | 'dark' | 'high-contrast', Palette> = {\n light: {\n '--sg-bg': '#ffffff',\n '--sg-fg': '#0f172a',\n '--sg-muted': '#64748b',\n '--sg-border': '#e2e8f0',\n '--sg-header-bg': '#f1f5f9',\n '--sg-header-fg': '#0f172a',\n '--sg-row-alt-bg': '#f8fafc',\n '--sg-row-hover-bg': '#eef2ff',\n '--sg-selection-bg': '#dbeafe',\n '--sg-input-bg': '#ffffff',\n '--sg-input-border': '#cbd5e1',\n },\n dark: {\n '--sg-bg': '#0f172a',\n '--sg-fg': '#f1f5f9',\n '--sg-muted': '#94a3b8',\n '--sg-border': '#334155',\n '--sg-header-bg': '#1e2433',\n '--sg-header-fg': '#f1f5f9',\n '--sg-row-alt-bg': '#1b2230',\n '--sg-row-hover-bg': '#232b3c',\n '--sg-selection-bg': '#1d3a73',\n '--sg-input-bg': '#1a2130',\n '--sg-input-border': '#2c3548',\n },\n 'high-contrast': {\n '--sg-bg': '#000000',\n '--sg-fg': '#ffffff',\n '--sg-muted': '#d1d5db',\n '--sg-border': '#ffffff',\n '--sg-header-bg': '#000000',\n '--sg-header-fg': '#ffffff',\n '--sg-row-alt-bg': '#111111',\n '--sg-row-hover-bg': '#1f2937',\n '--sg-selection-bg': '#1e40af',\n '--sg-input-bg': '#000000',\n '--sg-input-border': '#ffffff',\n },\n }\n const themeStyle = $derived(\n theme === 'auto'\n ? ''\n : Object.entries(THEME_PALETTES[theme])\n .map(([k, v]) => `${k}:${v}`)\n .join(';'),\n )\n\n // Snippets defined below are hoisted, so they are usable here.\n function buildColumns(): ColumnDef<typeof features, Person>[] {\n return [\n {\n id: 'person',\n header: 'Person',\n accessorFn: (row) => `${row.firstName} ${row.lastName}`,\n cell: (ctx) => renderSnippet(PersonCell, { row: ctx.row.original }),\n },\n { field: 'department', header: 'Department' },\n { field: 'country', header: 'Country', width: 80 },\n {\n field: 'status',\n header: 'Status',\n cell: (ctx) => renderSnippet(StatusPill, { value: String(ctx.getValue()) }),\n },\n {\n field: 'performance',\n header: 'Performance',\n editorType: 'number',\n cell: (ctx) => renderSnippet(PerformanceBar, { value: Number(ctx.getValue()) }),\n },\n {\n id: 'trend',\n header: 'Trend (12mo)',\n cell: (ctx) => renderSnippet(Sparkline, { row: ctx.row.original }),\n },\n {\n field: 'salary',\n header: 'Salary',\n editorType: 'number',\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n ]\n }\n const columns = buildColumns()\n</script>\n\n{#snippet PersonCell(props: { row: Person })}\n {@const initials = props.row.firstName.charAt(0) + props.row.lastName.charAt(0)}\n <span class=\"inline-flex items-center gap-2\">\n <span class=\"inline-flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-blue-700 text-xs font-semibold dark:bg-blue-900 dark:text-blue-200\">\n {initials}\n </span>\n <span>{props.row.firstName} {props.row.lastName}</span>\n </span>\n{/snippet}\n\n{#snippet StatusPill(props: { value: string })}\n <span class=\"pill pill-{props.value}\">{props.value}</span>\n{/snippet}\n\n{#snippet PerformanceBar(props: { value: number })}\n <div\n role=\"progressbar\"\n aria-label=\"performance\"\n aria-valuemin=\"0\"\n aria-valuemax=\"100\"\n aria-valuenow={props.value}\n class=\"inline-flex items-center gap-2\"\n >\n <div class=\"relative h-1.5 w-24 rounded bg-slate-200 dark:bg-slate-700\">\n <div class=\"absolute inset-y-0 left-0 rounded bg-blue-600\" style=\"width: {props.value}%\"></div>\n </div>\n <span class=\"text-xs tabular-nums w-7 text-right\">{props.value}</span>\n </div>\n{/snippet}\n\n{#snippet Sparkline(props: { row: Person })}\n {@const seed = props.row.id.length + props.row.age}\n {@const bars = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((i) => 4 + ((seed * (i + 3)) % 14))}\n <span class=\"sparkbar\" aria-label=\"trend\">\n {#each bars as h, i (i)}<span style=\"height: {h}px\"></span>{/each}\n </span>\n{/snippet}\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-3 text-sm shrink-0\">\n <label class=\"flex items-center gap-2\">\n Density:\n <select bind:value={density} class=\"rounded border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1\">\n <option value=\"compact\">Compact</option>\n <option value=\"normal\">Normal</option>\n <option value=\"comfortable\">Comfortable</option>\n </select>\n </label>\n <label class=\"flex items-center gap-2\">\n Theme:\n <select bind:value={theme} class=\"rounded border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1\">\n <option value=\"auto\">Auto (system)</option>\n <option value=\"light\">Light</option>\n <option value=\"dark\">Dark</option>\n <option value=\"high-contrast\">High contrast</option>\n </select>\n </label>\n <span class=\"text-slate-500 dark:text-slate-400\">All controls are driven by CSS custom properties.</span>\n </div>\n\n <div\n class=\"density-{density} flex flex-col flex-1 min-h-0\"\n data-theme={theme}\n style={`--sg-row-height: ${density === 'compact' ? '28px' : density === 'comfortable' ? '48px' : '36px'}; ${themeStyle}`}\n >\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"none\"\n selectionMode=\"cell\"\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={density === 'compact' ? 28 : density === 'comfortable' ? 48 : 36}\n containerHeight=\"100%\"\n />\n </div>\n</section>\n"
73
+ },
74
+ {
75
+ "id": "11-stock-market",
76
+ "path": "examples/src/demos/11-stock-market.svelte",
77
+ "title": "Stock Market",
78
+ "blurb": "11. Stock market - live updates Simulates a fast-moving market feed. A 250 ms interval randomly walks each symbol's last price, bid/ask, and cumulative volume. Cells flash",
79
+ "source": "<script lang=\"ts\">\n /**\n * 11. Stock market - live updates\n * -------------------------------\n * Simulates a fast-moving market feed. A 250 ms interval randomly walks\n * each symbol's last price, bid/ask, and cumulative volume. Cells flash\n * green on an up-tick, red on a down-tick.\n *\n * Implementation notes:\n * - Rows are kept in `$state.raw` so the grid sees a single new array\n * reference per tick instead of one mutation per cell.\n * - A `pulses` map keyed by `${symbol}:${col}` is set when the cell\n * changes. A 320 ms CSS animation reads `data-pulse=\"up|down\"` on the\n * cell DOM node and tints the background. The map is GC'd by the same\n * tick that wrote the entry - old keys are dropped, not appended.\n * - \"Pause\" stops the interval. Sort and selection still work paused.\n *\n * Replace `tick()` with your WebSocket onMessage handler; the rest of the\n * grid wiring stays the same.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n renderSnippet,\n type ColumnDef,\n } from 'sv-grid-community'\n import { getStockBrand } from '../shared/stock-logos'\n\n type Stock = {\n symbol: string\n name: string\n sector: string\n last: number\n change: number\n pctChange: number\n bid: number\n ask: number\n volume: number\n open: number\n high: number\n low: number\n // Fundamentals - don't tick, but worth showing alongside the live quotes.\n week52High: number\n week52Low: number\n marketCapB: number // billions USD\n peRatio: number\n eps: number\n divYield: number // %\n }\n\n const features = tableFeatures({ rowSortingFeature })\n\n type SeedRow = {\n symbol: string\n name: string\n sector: string\n price: number\n /** 52w high as a multiplier on `price` */\n h52: number\n /** 52w low as a multiplier on `price` */\n l52: number\n /** Market cap in billions USD */\n capB: number\n pe: number\n eps: number\n /** Dividend yield % */\n yld: number\n }\n const SEED: SeedRow[] = [\n { symbol: 'AAPL', name: 'Apple Inc.', sector: 'Technology', price: 187.42, h52: 1.16, l52: 0.85, capB: 2870, pe: 31.4, eps: 5.96, yld: 0.51 },\n { symbol: 'MSFT', name: 'Microsoft Corp.', sector: 'Technology', price: 412.11, h52: 1.10, l52: 0.78, capB: 3060, pe: 36.1, eps: 11.42, yld: 0.72 },\n { symbol: 'NVDA', name: 'NVIDIA Corp.', sector: 'Technology', price: 905.34, h52: 1.05, l52: 0.43, capB: 2230, pe: 73.8, eps: 12.27, yld: 0.02 },\n { symbol: 'GOOG', name: 'Alphabet Inc. Class C', sector: 'Technology', price: 158.20, h52: 1.12, l52: 0.74, capB: 1980, pe: 27.5, eps: 5.76, yld: 0.51 },\n { symbol: 'AMZN', name: 'Amazon.com Inc.', sector: 'Consumer', price: 182.95, h52: 1.08, l52: 0.65, capB: 1900, pe: 60.2, eps: 3.04, yld: 0 },\n { symbol: 'META', name: 'Meta Platforms Inc.', sector: 'Technology', price: 492.85, h52: 1.07, l52: 0.50, capB: 1260, pe: 32.7, eps: 15.10, yld: 0.40 },\n { symbol: 'TSLA', name: 'Tesla Inc.', sector: 'Automotive', price: 174.30, h52: 1.65, l52: 0.78, capB: 555, pe: 47.6, eps: 3.66, yld: 0 },\n { symbol: 'BRK.B',name: 'Berkshire Hathaway B', sector: 'Financials', price: 405.66, h52: 1.07, l52: 0.85, capB: 880, pe: 9.4, eps: 43.21, yld: 0 },\n { symbol: 'JPM', name: 'JPMorgan Chase & Co.', sector: 'Financials', price: 201.14, h52: 1.04, l52: 0.71, capB: 580, pe: 11.7, eps: 17.20, yld: 2.27 },\n { symbol: 'V', name: 'Visa Inc.', sector: 'Financials', price: 273.40, h52: 1.05, l52: 0.85, capB: 555, pe: 30.4, eps: 8.99, yld: 0.76 },\n { symbol: 'UNH', name: 'UnitedHealth Group Inc.', sector: 'Healthcare', price: 522.78, h52: 1.10, l52: 0.83, capB: 484, pe: 21.3, eps: 24.58, yld: 1.61 },\n { symbol: 'XOM', name: 'Exxon Mobil Corp.', sector: 'Energy', price: 113.05, h52: 1.10, l52: 0.84, capB: 450, pe: 13.8, eps: 8.19, yld: 3.36 },\n { symbol: 'JNJ', name: 'Johnson & Johnson', sector: 'Healthcare', price: 152.66, h52: 1.12, l52: 0.92, capB: 367, pe: 27.2, eps: 5.61, yld: 3.21 },\n { symbol: 'WMT', name: 'Walmart Inc.', sector: 'Consumer', price: 68.50, h52: 1.07, l52: 0.78, capB: 552, pe: 30.2, eps: 2.27, yld: 1.21 },\n { symbol: 'PG', name: 'Procter & Gamble Co.', sector: 'Consumer', price: 165.20, h52: 1.05, l52: 0.85, capB: 391, pe: 27.3, eps: 6.05, yld: 2.33 },\n { symbol: 'HD', name: 'Home Depot Inc.', sector: 'Consumer', price: 348.10, h52: 1.10, l52: 0.78, capB: 346, pe: 23.5, eps: 14.81, yld: 2.59 },\n { symbol: 'BAC', name: 'Bank of America Corp.', sector: 'Financials', price: 39.95, h52: 1.10, l52: 0.62, capB: 318, pe: 12.8, eps: 3.12, yld: 2.40 },\n { symbol: 'ADBE', name: 'Adobe Inc.', sector: 'Technology', price: 472.30, h52: 1.45, l52: 0.86, capB: 213, pe: 41.6, eps: 11.35, yld: 0 },\n { symbol: 'NFLX', name: 'Netflix Inc.', sector: 'Communication', price: 612.45, h52: 1.10, l52: 0.55, capB: 265, pe: 47.0, eps: 13.03, yld: 0 },\n { symbol: 'CRM', name: 'Salesforce Inc.', sector: 'Technology', price: 263.10, h52: 1.30, l52: 0.74, capB: 255, pe: 45.2, eps: 5.82, yld: 0.61 },\n { symbol: 'CVX', name: 'Chevron Corp.', sector: 'Energy', price: 151.20, h52: 1.15, l52: 0.92, capB: 281, pe: 14.0, eps: 10.81, yld: 4.32 },\n { symbol: 'KO', name: 'Coca-Cola Co.', sector: 'Consumer', price: 62.80, h52: 1.07, l52: 0.84, capB: 271, pe: 25.0, eps: 2.51, yld: 3.10 },\n { symbol: 'PEP', name: 'PepsiCo Inc.', sector: 'Consumer', price: 170.40, h52: 1.11, l52: 0.92, capB: 234, pe: 25.7, eps: 6.63, yld: 3.18 },\n { symbol: 'COST', name: 'Costco Wholesale Corp.', sector: 'Consumer', price: 828.55, h52: 1.08, l52: 0.61, capB: 367, pe: 51.8, eps: 16.00, yld: 0.55 },\n { symbol: 'AVGO', name: 'Broadcom Inc.', sector: 'Technology', price: 1389.20,h52: 1.05, l52: 0.55, capB: 644, pe: 53.4, eps: 26.00, yld: 1.51 },\n ]\n\n function initialRows(): Stock[] {\n return SEED.map((s) => ({\n symbol: s.symbol,\n name: s.name,\n sector: s.sector,\n last: s.price,\n change: 0,\n pctChange: 0,\n bid: round(s.price - 0.05, 2),\n ask: round(s.price + 0.05, 2),\n volume: Math.floor(50_000 + Math.random() * 950_000),\n open: s.price,\n high: s.price,\n low: s.price,\n week52High: round(s.price * s.h52, 2),\n week52Low: round(s.price * s.l52, 2),\n marketCapB: s.capB,\n peRatio: s.pe,\n eps: s.eps,\n divYield: s.yld,\n }))\n }\n\n function round(n: number, places: number): number {\n const p = 10 ** places\n return Math.round(n * p) / p\n }\n\n let rows = $state.raw<Stock[]>(initialRows())\n let pulses = $state.raw<Record<string, 'up' | 'down'>>({})\n let paused = $state(false)\n let tickIntervalMs = $state(250)\n let ticks = $state(0)\n\n // The columns that should flash on change. Avoids re-pulsing static columns\n // like Symbol or Name.\n const PULSED_COLS = ['last', 'change', 'pctChange', 'bid', 'ask', 'volume', 'high', 'low']\n\n function tick() {\n const nextPulses: Record<string, 'up' | 'down'> = {}\n const nextRows = rows.map((row) => {\n // ~70% of symbols move per tick - keeps the list lively without flooding.\n if (Math.random() < 0.3) return row\n const drift = (Math.random() - 0.5) * row.last * 0.004 // up to ±0.2%\n const newLast = Math.max(round(row.last + drift, 2), 0.01)\n if (newLast === row.last) return row\n const direction: 'up' | 'down' = newLast > row.last ? 'up' : 'down'\n const change = round(newLast - row.open, 2)\n const pctChange = round((change / row.open) * 100, 2)\n const spread = Math.max(round(row.last * 0.0005, 2), 0.01)\n const next: Stock = {\n ...row,\n last: newLast,\n change,\n pctChange,\n bid: round(newLast - spread, 2),\n ask: round(newLast + spread, 2),\n volume: row.volume + Math.floor(100 + Math.random() * 5_000),\n high: Math.max(row.high, newLast),\n low: Math.min(row.low, newLast),\n }\n for (const col of PULSED_COLS) nextPulses[`${row.symbol}:${col}`] = direction\n return next\n })\n rows = nextRows\n pulses = nextPulses\n ticks += 1\n }\n\n $effect(() => {\n if (paused) return\n const id = setInterval(tick, tickIntervalMs)\n return () => clearInterval(id)\n })\n\n function pulseClass(row: Stock, colId: string): 'sv-pulse-up' | 'sv-pulse-down' | '' {\n const dir = pulses[`${row.symbol}:${colId}`]\n return dir === 'up' ? 'sv-pulse-up' : dir === 'down' ? 'sv-pulse-down' : ''\n }\n</script>\n\n{#snippet PulsedNumber(props: { row: Stock; colId: string; value: string; align?: 'right' })}\n <span class={`sv-tick ${pulseClass(props.row, props.colId)}`}>{props.value}</span>\n{/snippet}\n\n{#snippet SymbolCell(props: { row: Stock })}\n {@const brand = getStockBrand(props.row.symbol)}\n <span class=\"sv-symbol\">\n <span\n class=\"sv-logo\"\n style:background={brand.bg}\n style:color={brand.fg ?? '#fff'}\n aria-hidden=\"true\"\n >\n {#if brand.svg}\n <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" width=\"14\" height=\"14\">\n <path d={brand.svg} />\n </svg>\n {:else}\n <span class=\"sv-logo-mark\">{brand.mark}</span>\n {/if}\n </span>\n <span class=\"sv-symbol-text\">{props.row.symbol}</span>\n </span>\n{/snippet}\n\n{#snippet ChangeCell(props: { row: Stock })}\n {@const positive = props.row.change >= 0}\n <span class={`sv-tick ${pulseClass(props.row, 'change')} ${positive ? 'sv-change-up' : 'sv-change-down'}`}>\n <svg class=\"sv-arrow\" viewBox=\"0 0 12 12\" width=\"10\" height=\"10\" aria-hidden=\"true\">\n {#if positive}\n <path d=\"M6 2 L11 9 L1 9 Z\" fill=\"currentColor\" />\n {:else}\n <path d=\"M6 10 L1 3 L11 3 Z\" fill=\"currentColor\" />\n {/if}\n </svg>\n {Math.abs(props.row.change).toFixed(2)}\n </span>\n{/snippet}\n\n{#snippet PctChangeCell(props: { row: Stock })}\n {@const positive = props.row.pctChange >= 0}\n <span class={`sv-tick ${pulseClass(props.row, 'pctChange')} ${positive ? 'sv-change-up' : 'sv-change-down'}`}>\n {positive ? '+' : ''}{props.row.pctChange.toFixed(2)}%\n </span>\n{/snippet}\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-3 text-sm shrink-0\">\n <button\n type=\"button\"\n onclick={() => (paused = !paused)}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-3 py-1 hover:bg-slate-100 dark:hover:bg-slate-800\"\n >\n {paused ? '▶ Resume' : '⏸ Pause'}\n </button>\n <label class=\"flex items-center gap-2 text-slate-600 dark:text-slate-300\">\n Tick interval:\n <select\n bind:value={tickIntervalMs}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-2 py-1\"\n >\n <option value={100}>100 ms (firehose)</option>\n <option value={250}>250 ms (live)</option>\n <option value={500}>500 ms (smooth)</option>\n <option value={1000}>1 s (calm)</option>\n </select>\n </label>\n <span class=\"ml-auto text-slate-500 dark:text-slate-400 tabular-nums\">\n {ticks.toLocaleString()} ticks · {rows.length} symbols\n </span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={[\n {\n field: 'symbol', header: 'Symbol', width: 130,\n cell: (ctx) => renderSnippet(SymbolCell, { row: ctx.row.original }),\n },\n { field: 'name', header: 'Name', width: 220 },\n { field: 'sector', header: 'Sector', width: 130 },\n {\n field: 'last', header: 'Last', editorType: 'number', width: 110,\n format: { type: 'currency', currency: 'USD' },\n cell: (ctx) => renderSnippet(PulsedNumber, {\n row: ctx.row.original,\n colId: 'last',\n value: `$${ctx.row.original.last.toFixed(2)}`,\n }),\n },\n {\n field: 'change', header: 'Chg', editorType: 'number', width: 90,\n cell: (ctx) => renderSnippet(ChangeCell, { row: ctx.row.original }),\n },\n {\n field: 'pctChange', header: 'Chg %', editorType: 'number', width: 95,\n cell: (ctx) => renderSnippet(PctChangeCell, { row: ctx.row.original }),\n },\n {\n field: 'bid', header: 'Bid', editorType: 'number', width: 90,\n cell: (ctx) => renderSnippet(PulsedNumber, {\n row: ctx.row.original,\n colId: 'bid',\n value: ctx.row.original.bid.toFixed(2),\n }),\n },\n {\n field: 'ask', header: 'Ask', editorType: 'number', width: 90,\n cell: (ctx) => renderSnippet(PulsedNumber, {\n row: ctx.row.original,\n colId: 'ask',\n value: ctx.row.original.ask.toFixed(2),\n }),\n },\n {\n field: 'volume', header: 'Volume', editorType: 'number', width: 125,\n cell: (ctx) => renderSnippet(PulsedNumber, {\n row: ctx.row.original,\n colId: 'volume',\n value: ctx.row.original.volume.toLocaleString(),\n }),\n },\n {\n field: 'open', header: 'Open', editorType: 'number', width: 90,\n format: { type: 'number', options: { minimumFractionDigits: 2, maximumFractionDigits: 2 } },\n },\n {\n field: 'high', header: 'High', editorType: 'number', width: 90,\n cell: (ctx) => renderSnippet(PulsedNumber, {\n row: ctx.row.original,\n colId: 'high',\n value: ctx.row.original.high.toFixed(2),\n }),\n },\n {\n field: 'low', header: 'Low', editorType: 'number', width: 90,\n cell: (ctx) => renderSnippet(PulsedNumber, {\n row: ctx.row.original,\n colId: 'low',\n value: ctx.row.original.low.toFixed(2),\n }),\n },\n {\n field: 'week52High', header: '52w High', editorType: 'number', width: 110,\n format: { type: 'number', options: { minimumFractionDigits: 2, maximumFractionDigits: 2 } },\n },\n {\n field: 'week52Low', header: '52w Low', editorType: 'number', width: 110,\n format: { type: 'number', options: { minimumFractionDigits: 2, maximumFractionDigits: 2 } },\n },\n {\n field: 'marketCapB', header: 'Mkt Cap', editorType: 'number', width: 120,\n cell: (ctx) => {\n const v = ctx.row.original.marketCapB\n const display = v >= 1000 ? `${(v / 1000).toFixed(2)} T` : `${v.toFixed(0)} B`\n return display\n },\n },\n {\n field: 'peRatio', header: 'P/E', editorType: 'number', width: 80,\n format: { type: 'number', options: { minimumFractionDigits: 1, maximumFractionDigits: 1 } },\n },\n {\n field: 'eps', header: 'EPS', editorType: 'number', width: 90,\n format: { type: 'currency', currency: 'USD', options: { minimumFractionDigits: 2, maximumFractionDigits: 2 } },\n },\n {\n field: 'divYield', header: 'Div Yield', editorType: 'number', width: 100,\n cell: (ctx) => `${ctx.row.original.divYield.toFixed(2)}%`,\n },\n ] satisfies ColumnDef<typeof features, Stock>[]}\n features={features}\n filterMode=\"none\"\n selectionMode=\"cell\"\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={32}\n containerHeight=\"100%\"\n />\n </div>\n</section>\n\n<style>\n /* Flash on cell change. The animation lives 320 ms; subsequent ticks\n * either re-trigger it (same direction) or replace it with the opposite\n * tint. Background-only - leaves the cell's text color untouched. */\n :global(.sv-tick) {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n padding: 0 2px;\n border-radius: 3px;\n font-variant-numeric: tabular-nums;\n }\n :global(.sv-arrow) {\n flex-shrink: 0;\n }\n :global(.sv-symbol) {\n display: inline-flex;\n align-items: center;\n gap: 8px;\n height: 100%;\n }\n :global(.sv-logo) {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 22px;\n height: 22px;\n border-radius: 6px;\n flex-shrink: 0;\n overflow: hidden;\n box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18);\n }\n :global(.sv-logo svg) {\n display: block;\n }\n :global(.sv-logo-mark) {\n font-size: 9px;\n font-weight: 700;\n letter-spacing: 0.02em;\n line-height: 1;\n font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;\n }\n :global(.sv-symbol-text) {\n font-weight: 600;\n letter-spacing: 0.01em;\n }\n :global(.sv-pulse-up) {\n animation: sv-pulse-up 320ms ease-out;\n }\n :global(.sv-pulse-down) {\n animation: sv-pulse-down 320ms ease-out;\n }\n :global(.sv-change-up) {\n color: #16a34a;\n font-weight: 600;\n }\n :global(.sv-change-down) {\n color: #dc2626;\n font-weight: 600;\n }\n :global(:where([data-theme='dark']) .sv-change-up) {\n color: #4ade80;\n }\n :global(:where([data-theme='dark']) .sv-change-down) {\n color: #f87171;\n }\n @keyframes sv-pulse-up {\n 0% { background: rgba(34, 197, 94, 0.55); }\n 100% { background: transparent; }\n }\n @keyframes sv-pulse-down {\n 0% { background: rgba(239, 68, 68, 0.55); }\n 100% { background: transparent; }\n }\n</style>\n"
80
+ },
81
+ {
82
+ "id": "12-hr-team",
83
+ "path": "examples/src/demos/12-hr-team.svelte",
84
+ "title": "Hr Team",
85
+ "blurb": "12. HR team directory A directory of ~80 employees with the columns an HR app actually needs: person (avatar + name), title, level, team, manager, location, start",
86
+ "source": "<script lang=\"ts\">\n /**\n * 12. HR team directory\n * ---------------------\n * A directory of ~80 employees with the columns an HR app actually needs:\n * person (avatar + name), title, level, team, manager, location, start\n * date, tenure, status, comp.\n *\n * Showcases:\n * - `columnGroupingFeature` with a default group-by (\"team\")\n * - `renderSnippet` for the avatar cell and status badge\n * - A derived `tenure` column (no `field` - `accessorFn`)\n * - Sort by any column; group rows fold to a summary line\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n renderSnippet,\n type ColumnDef,\n type SvGridApi,\n } from 'sv-grid-community'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n type Status = 'Active' | 'On leave' | 'Contractor'\n type Person = {\n id: string\n firstName: string\n lastName: string\n title: string\n level: string\n team: string\n manager: string\n location: string\n startDate: string // ISO date\n status: Status\n salary: number\n bonusPct: number\n }\n\n // ------------------------------------------------------------------ data\n const TEAMS = [\n 'Engineering', 'Design', 'Product', 'Data', 'Marketing',\n 'Sales', 'Support', 'Operations', 'Finance', 'People',\n ] as const\n type Team = typeof TEAMS[number]\n\n const TITLES: Record<Team, string[]> = {\n Engineering: ['Software Engineer', 'Senior Engineer', 'Staff Engineer', 'Engineering Manager', 'Director of Engineering'],\n Design: ['Product Designer', 'Senior Designer', 'Design Lead', 'Director of Design'],\n Product: ['Associate PM', 'Product Manager', 'Senior PM', 'Group PM', 'Director of Product'],\n Data: ['Data Analyst', 'Data Scientist', 'ML Engineer', 'Analytics Manager'],\n Marketing: ['Marketing Specialist', 'Growth Manager', 'Brand Lead', 'CMO'],\n Sales: ['SDR', 'AE', 'Senior AE', 'Sales Manager', 'VP Sales'],\n Support: ['Support Engineer', 'Senior Support', 'Support Lead'],\n Operations: ['Ops Analyst', 'Operations Manager', 'COO'],\n Finance: ['Accountant', 'Finance Analyst', 'Controller', 'CFO'],\n People: ['HR Generalist', 'Recruiter', 'People Partner', 'CHRO'],\n }\n\n const LEVELS = ['IC2', 'IC3', 'IC4', 'IC5', 'IC6', 'M2', 'M3', 'M4', 'M5']\n const LOCATIONS = [\n 'San Francisco, US', 'New York, US', 'Austin, US', 'London, UK', 'Berlin, DE',\n 'Amsterdam, NL', 'Toronto, CA', 'Sydney, AU', 'Tokyo, JP', 'Singapore, SG',\n 'Remote - EMEA', 'Remote - Americas',\n ]\n const FIRST_NAMES = [\n 'Aiden', 'Bea', 'Cara', 'Dev', 'Elena', 'Felix', 'Grace', 'Hannah', 'Ivan',\n 'Jules', 'Kai', 'Lena', 'Marco', 'Nina', 'Omar', 'Priya', 'Quinn', 'Rosa',\n 'Sven', 'Tess', 'Uma', 'Victor', 'Wren', 'Xio', 'Yael', 'Zane', 'Anika',\n 'Beck', 'Cyrus', 'Dara', 'Eitan', 'Fiona', 'Gus', 'Henri', 'Ines', 'Jonas',\n 'Kira', 'Luca', 'Mara', 'Nico', 'Olin', 'Pia', 'Quentin', 'Reyna', 'Sami',\n ]\n const LAST_NAMES = [\n 'Ortega', 'Nakamura', 'Schmidt', 'Hassan', 'Petrov', 'Yamada', 'Hughes',\n 'Marchetti', 'Anand', 'Lefevre', 'Olsen', 'Carmona', 'Tan', 'Beck',\n 'Mustafa', 'Andersson', 'Vogel', 'Romero', 'Kowalski', 'Mendoza',\n 'Sokolova', 'Voss', 'Holm', 'Park', 'Khoury', 'Bauer', 'Russo',\n ]\n\n // Seeded PRNG so the demo is reproducible across reloads.\n let prngState = 0x9E3779B1\n function rand(): number {\n prngState = (prngState * 1664525 + 1013904223) >>> 0\n return prngState / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rand() * arr.length)]! }\n function range(lo: number, hi: number): number { return lo + Math.floor(rand() * (hi - lo + 1)) }\n\n function isoDate(daysAgo: number): string {\n const ms = Date.now() - daysAgo * 86_400_000\n return new Date(ms).toISOString().slice(0, 10)\n }\n\n function makePeople(): Person[] {\n const out: Person[] = []\n // 1. Pick one manager per team first so other rows can reference them.\n const managerByTeam: Record<string, string> = {}\n for (const team of TEAMS) {\n const first = pick(FIRST_NAMES)\n const last = pick(LAST_NAMES)\n const titles = TITLES[team]\n const title = titles[titles.length - 1]! // most senior title for the team's head\n out.push({\n id: `E${(out.length + 1).toString().padStart(3, '0')}`,\n firstName: first,\n lastName: last,\n title,\n level: pick(['M3', 'M4', 'M5']),\n team,\n manager: 'Sasha Lin (CEO)',\n location: pick(LOCATIONS),\n startDate: isoDate(range(1500, 4200)),\n status: 'Active',\n salary: range(180, 360) * 1000,\n bonusPct: range(15, 30),\n })\n managerByTeam[team] = `${first} ${last}`\n }\n // 2. Fill out 6–10 ICs per team reporting to that team's head.\n for (const team of TEAMS) {\n const headcount = range(6, 10)\n const icTitles = TITLES[team].slice(0, -1) // drop the manager title\n for (let i = 0; i < headcount; i += 1) {\n out.push({\n id: `E${(out.length + 1).toString().padStart(3, '0')}`,\n firstName: pick(FIRST_NAMES),\n lastName: pick(LAST_NAMES),\n title: pick(icTitles),\n level: pick(LEVELS.slice(0, 6)), // IC-only levels\n team,\n manager: managerByTeam[team]!,\n location: pick(LOCATIONS),\n startDate: isoDate(range(60, 2400)),\n status: pick<Status>(['Active', 'Active', 'Active', 'Active', 'On leave', 'Contractor']),\n salary: range(85, 220) * 1000,\n bonusPct: range(5, 18),\n })\n }\n }\n return out\n }\n\n const rows = makePeople()\n\n // Tenure in years, derived from startDate; used by an `accessorFn` column.\n function tenureYears(p: Person): number {\n const start = new Date(p.startDate).getTime()\n const now = Date.now()\n return Math.round(((now - start) / (365.25 * 86_400_000)) * 10) / 10\n }\n\n function initials(p: Person): string {\n return `${p.firstName[0] ?? ''}${p.lastName[0] ?? ''}`.toUpperCase()\n }\n\n // Stable avatar tint per team - keeps the directory feeling like one company\n // not a rainbow soup.\n const TEAM_COLORS: Record<string, string> = {\n Engineering: '#2563eb', Design: '#db2777', Product: '#7c3aed',\n Data: '#0891b2', Marketing: '#db2516', Sales: '#16a34a',\n Support: '#ca8a04', Operations: '#9333ea', Finance: '#0d9488',\n People: '#e11d48',\n }\n\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n let groupBy = $state<string[]>(['team'])\n\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n</script>\n\n{#snippet PersonCell(props: { row: Person })}\n <span class=\"hr-person\">\n <span class=\"hr-avatar\" style=\"background:{TEAM_COLORS[props.row.team] ?? '#475569'}\">\n {initials(props.row)}\n </span>\n <span class=\"hr-person-text\">\n <span class=\"hr-name\">{props.row.firstName} {props.row.lastName}</span>\n <span class=\"hr-id\">{props.row.id}</span>\n </span>\n </span>\n{/snippet}\n\n{#snippet StatusBadge(props: { value: Status })}\n <span class=\"hr-badge hr-status-{props.value.replace(/\\s+/g, '').toLowerCase()}\">{props.value}</span>\n{/snippet}\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <span class=\"font-medium\">Group by:</span>\n <button\n onclick={() => applyGroup([])}\n class=\"rounded border px-3 py-1 {groupBy.length === 0 ? 'bg-slate-200 dark:bg-slate-700' : 'border-slate-300 dark:border-slate-600'}\"\n >None</button>\n <button\n onclick={() => applyGroup(['team'])}\n class=\"rounded border px-3 py-1 {groupBy.join() === 'team' ? 'bg-slate-200 dark:bg-slate-700' : 'border-slate-300 dark:border-slate-600'}\"\n >Team</button>\n <button\n onclick={() => applyGroup(['location'])}\n class=\"rounded border px-3 py-1 {groupBy.join() === 'location' ? 'bg-slate-200 dark:bg-slate-700' : 'border-slate-300 dark:border-slate-600'}\"\n >Location</button>\n <button\n onclick={() => applyGroup(['status'])}\n class=\"rounded border px-3 py-1 {groupBy.join() === 'status' ? 'bg-slate-200 dark:bg-slate-700' : 'border-slate-300 dark:border-slate-600'}\"\n >Status</button>\n <span class=\"ml-auto text-slate-500 dark:text-slate-400\">{rows.length} employees</span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={[\n {\n id: 'person',\n header: 'Person',\n accessorFn: (row) => `${row.firstName} ${row.lastName}`,\n cell: (ctx) => renderSnippet(PersonCell, { row: ctx.row.original }),\n width: 240,\n },\n { field: 'title', header: 'Title', editorType: 'text', width: 200 },\n { field: 'level', header: 'Level', editorType: 'text', width: 80 },\n { field: 'team', header: 'Team', editorType: 'text', width: 130 },\n { field: 'manager', header: 'Manager', editorType: 'text', width: 180 },\n { field: 'location', header: 'Location', editorType: 'text', width: 180 },\n {\n field: 'startDate', header: 'Start date', editorType: 'date', width: 120,\n format: { type: 'date', pattern: 'y-m-d' },\n },\n {\n id: 'tenure', header: 'Tenure', editorType: 'number',\n accessorFn: (row) => tenureYears(row),\n width: 100,\n cell: (ctx) => `${(ctx.getValue() as number).toFixed(1)}y`,\n },\n {\n field: 'status', header: 'Status', editorType: 'text', width: 120,\n cell: (ctx) => renderSnippet(StatusBadge, { value: ctx.row.original.status }),\n },\n {\n field: 'salary', header: 'Salary', editorType: 'number', width: 120,\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n {\n field: 'bonusPct', header: 'Bonus %', editorType: 'number', width: 100,\n cell: (ctx) => `${ctx.row.original.bonusPct}%`,\n },\n ] satisfies ColumnDef<typeof features, Person>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showPagination={false}\n showRowNumbers={true}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={true}\n rowHeight={44}\n containerHeight=\"100%\"\n onApiReady={(next) => {\n api = next\n next.setGroupBy(groupBy)\n }}\n />\n </div>\n</section>\n\n<style>\n .hr-person {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n min-width: 0;\n }\n .hr-avatar {\n flex: 0 0 auto;\n width: 28px;\n height: 28px;\n border-radius: 50%;\n color: #fff;\n font-size: 11px;\n font-weight: 700;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n letter-spacing: 0.5px;\n }\n .hr-person-text {\n display: inline-flex;\n flex-direction: column;\n line-height: 1.15;\n min-width: 0;\n }\n .hr-name { font-weight: 600; }\n .hr-id { font-size: 11px; color: var(--sg-muted, #94a3b8); }\n .hr-badge {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n line-height: 1.4;\n }\n .hr-status-active { background: #dcfce7; color: #166534; }\n .hr-status-onleave { background: #fef3c7; color: #92400e; }\n .hr-status-contractor { background: #e0e7ff; color: #4338ca; }\n :global([data-theme='dark']) .hr-status-active { background: rgba(34, 197, 94, 0.18); color: #4ade80; }\n :global([data-theme='dark']) .hr-status-onleave { background: rgba(245, 158, 11, 0.18); color: #fbbf24; }\n :global([data-theme='dark']) .hr-status-contractor { background: rgba(99, 102, 241, 0.18); color: #a5b4fc; }\n</style>\n"
87
+ },
88
+ {
89
+ "id": "13-finances",
90
+ "path": "examples/src/demos/13-finances.svelte",
91
+ "title": "Finances",
92
+ "blurb": "13. Finances - account ledger A chequing/savings ledger with running balance, currency formatting, category chips, and a status column. ~600 transactions across three",
93
+ "source": "<script lang=\"ts\">\n /**\n * 13. Finances - account ledger\n * -----------------------------\n * A chequing/savings ledger with running balance, currency formatting,\n * category chips, and a status column. ~600 transactions across three\n * accounts; pick one with the dropdown to switch the view.\n *\n * Running balance is computed ONCE in chronological order at load time\n * (and re-computed when the account changes) - so it's stable regardless\n * of how the user later sorts or filters the visible rows.\n *\n * Showcases:\n * - Pagination\n * - Per-column filtering via the funnel menu\n * - Currency + date formatters\n * - Custom cell renderers for category chips and signed amounts\n * - Row summaries (sum of money-in / money-out) in the footer\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n renderSnippet,\n type ColumnDef,\n } from 'sv-grid-community'\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n type Status = 'Cleared' | 'Pending' | 'Disputed'\n type Category =\n | 'Salary' | 'Bonus' | 'Refund' | 'Transfer in'\n | 'Rent' | 'Groceries' | 'Utilities' | 'Subscription'\n | 'Dining' | 'Travel' | 'Health' | 'Tax' | 'Misc'\n\n type Tx = {\n id: string\n date: string // ISO yyyy-mm-dd\n description: string\n category: Category\n account: string\n amount: number // positive = credit, negative = debit\n balance: number // running balance after this tx\n status: Status\n currency: 'USD' | 'EUR' | 'GBP'\n }\n\n const ACCOUNTS: Array<{ id: string; label: string; opening: number; currency: 'USD' | 'EUR' | 'GBP' }> = [\n { id: 'cheq-1', label: 'Chequing · ••1924', opening: 12_480.55, currency: 'USD' },\n { id: 'sav-1', label: 'Savings · ••7741', opening: 58_320.00, currency: 'USD' },\n { id: 'eur-1', label: 'EUR business · ••3318', opening: 24_900.00, currency: 'EUR' },\n ]\n\n // ---------- seeded PRNG so the demo is reproducible across reloads.\n let prngState = 0xA1E2F3B4\n function rand(): number {\n prngState = (prngState * 1664525 + 1013904223) >>> 0\n return prngState / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rand() * arr.length)]! }\n function range(lo: number, hi: number): number { return lo + Math.floor(rand() * (hi - lo + 1)) }\n\n // Each category has a typical merchant pool + a sign + a price band. Keeps\n // descriptions varied while staying internally consistent (no $4 of rent,\n // no $4000 coffees).\n type CatProfile = {\n cat: Category\n sign: 1 | -1\n merchants: readonly string[]\n min: number\n max: number\n weight: number\n }\n const CATEGORY_TABLE: CatProfile[] = [\n { cat: 'Salary', sign: 1, merchants: ['ACME Payroll', 'Globex HR'], min: 3_200, max: 7_400, weight: 1 },\n { cat: 'Bonus', sign: 1, merchants: ['ACME Bonus'], min: 400, max: 3_500, weight: 1 },\n { cat: 'Refund', sign: 1, merchants: ['Amazon Refund', 'Apple Refund', 'Lufthansa Refund'], min: 12, max: 380, weight: 2 },\n { cat: 'Transfer in', sign: 1, merchants: ['Transfer from Savings', 'Wise transfer'], min: 50, max: 2_500, weight: 2 },\n { cat: 'Rent', sign: -1, merchants: ['Greenhill Apartments'], min: 1_800, max: 2_400, weight: 1 },\n { cat: 'Groceries', sign: -1, merchants: ['Whole Foods', 'Trader Joe\\'s', 'Aldi', 'Carrefour'], min: 18, max: 220, weight: 8 },\n { cat: 'Utilities', sign: -1, merchants: ['PG&E', 'Comcast', 'Verizon', 'Town Water'], min: 24, max: 240, weight: 4 },\n { cat: 'Subscription', sign: -1, merchants: ['Netflix', 'Spotify', 'iCloud', 'Adobe', 'NYT'], min: 4, max: 60, weight: 6 },\n { cat: 'Dining', sign: -1, merchants: ['Blue Bottle Coffee', 'Sushi Ran', 'Tartine', 'Chipotle'], min: 6, max: 140, weight: 12 },\n { cat: 'Travel', sign: -1, merchants: ['Uber', 'Lyft', 'United Airlines', 'Marriott'], min: 9, max: 950, weight: 5 },\n { cat: 'Health', sign: -1, merchants: ['CVS Pharmacy', 'Walgreens', 'Kaiser'], min: 14, max: 320, weight: 3 },\n { cat: 'Tax', sign: -1, merchants: ['IRS', 'State Tax Board'], min: 200, max: 2_400, weight: 1 },\n { cat: 'Misc', sign: -1, merchants: ['Amazon', 'Etsy', 'Home Depot', 'IKEA'], min: 6, max: 480, weight: 6 },\n ]\n\n // Pre-build a weighted pool for fast pick().\n const CATEGORY_POOL: CatProfile[] = (() => {\n const out: CatProfile[] = []\n for (const c of CATEGORY_TABLE) for (let i = 0; i < c.weight; i += 1) out.push(c)\n return out\n })()\n\n function makeLedger(accountId: string): Tx[] {\n const account = ACCOUNTS.find((a) => a.id === accountId)!\n // 600 transactions back from today\n const N = 600\n const start = Date.now() - 365 * 86_400_000\n const span = Date.now() - start\n\n // Generate in chronological order so the running balance is correct.\n const txs: Tx[] = []\n let balance = account.opening\n for (let i = 0; i < N; i += 1) {\n const profile = pick(CATEGORY_POOL)\n const merchant = pick(profile.merchants)\n const raw = range(profile.min, profile.max) + rand()\n const amount = Math.round(raw * 100) / 100 * profile.sign\n balance = Math.round((balance + amount) * 100) / 100\n const ts = start + (i / N) * span + rand() * (span / N)\n txs.push({\n id: `T-${accountId.toUpperCase()}-${(i + 1).toString().padStart(4, '0')}`,\n date: new Date(ts).toISOString().slice(0, 10),\n description: merchant,\n category: profile.cat,\n account: account.label,\n amount,\n balance,\n // 90% cleared, 8% pending, 2% disputed\n status: rand() < 0.9 ? 'Cleared' : rand() < 0.8 ? 'Pending' : 'Disputed',\n currency: account.currency,\n })\n }\n // Show most-recent first by default.\n return txs.reverse()\n }\n\n let selectedAccount = $state(ACCOUNTS[0]!.id)\n // svelte-ignore state_referenced_locally\n let rows = $state<Tx[]>(makeLedger(selectedAccount))\n $effect(() => { rows = makeLedger(selectedAccount) })\n\n const account = $derived(ACCOUNTS.find((a) => a.id === selectedAccount)!)\n\n // Quick summary for the toolbar - money in vs out over the whole ledger.\n const summary = $derived.by(() => {\n let inflow = 0\n let outflow = 0\n for (const tx of rows) {\n if (tx.amount > 0) inflow += tx.amount\n else outflow += -tx.amount\n }\n return {\n inflow: Math.round(inflow * 100) / 100,\n outflow: Math.round(outflow * 100) / 100,\n latestBalance: rows[0]?.balance ?? account.opening,\n }\n })\n\n const CATEGORY_TINT: Record<Category, string> = {\n 'Salary': '#16a34a',\n 'Bonus': '#15803d',\n 'Refund': '#0891b2',\n 'Transfer in': '#0d9488',\n 'Rent': '#b45309',\n 'Groceries': '#65a30d',\n 'Utilities': '#0369a1',\n 'Subscription': '#7c3aed',\n 'Dining': '#db2777',\n 'Travel': '#2563eb',\n 'Health': '#dc2626',\n 'Tax': '#9f1239',\n 'Misc': '#475569',\n }\n\n function fmtMoney(value: number, currency: 'USD' | 'EUR' | 'GBP'): string {\n return new Intl.NumberFormat(undefined, {\n style: 'currency', currency, maximumFractionDigits: 2,\n }).format(value)\n }\n</script>\n\n{#snippet CategoryChip(props: { value: Category })}\n <span class=\"ledger-chip\" style=\"background:{CATEGORY_TINT[props.value]}20; color:{CATEGORY_TINT[props.value]}; border:1px solid {CATEGORY_TINT[props.value]}40;\">\n {props.value}\n </span>\n{/snippet}\n\n{#snippet SignedAmount(props: { row: Tx })}\n <span class={props.row.amount >= 0 ? 'ledger-amount-pos' : 'ledger-amount-neg'}>\n {props.row.amount >= 0 ? '+' : '−'}{fmtMoney(Math.abs(props.row.amount), props.row.currency)}\n </span>\n{/snippet}\n\n{#snippet StatusBadge(props: { value: Status })}\n <span class=\"ledger-status ledger-status-{props.value.toLowerCase()}\">{props.value}</span>\n{/snippet}\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-end gap-4 text-sm shrink-0\">\n <label class=\"flex flex-col\">\n <span class=\"text-slate-500 dark:text-slate-400\">Account</span>\n <select\n bind:value={selectedAccount}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-2 py-1 min-w-56\"\n >\n {#each ACCOUNTS as a (a.id)}\n <option value={a.id}>{a.label} · {a.currency}</option>\n {/each}\n </select>\n </label>\n <div class=\"ml-auto flex items-end gap-6 text-right\">\n <div>\n <div class=\"text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400\">Money in</div>\n <div class=\"text-base font-semibold text-emerald-600 dark:text-emerald-400 tabular-nums\">\n {fmtMoney(summary.inflow, account.currency)}\n </div>\n </div>\n <div>\n <div class=\"text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400\">Money out</div>\n <div class=\"text-base font-semibold text-rose-600 dark:text-rose-400 tabular-nums\">\n {fmtMoney(summary.outflow, account.currency)}\n </div>\n </div>\n <div>\n <div class=\"text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400\">Latest balance</div>\n <div class=\"text-base font-semibold tabular-nums\">\n {fmtMoney(summary.latestBalance, account.currency)}\n </div>\n </div>\n </div>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={[\n {\n field: 'date', header: 'Date', editorType: 'date', width: 120,\n format: { type: 'date', pattern: 'y-m-d' },\n },\n { field: 'id', header: 'Tx', editorType: 'text', width: 140 },\n { field: 'description', header: 'Description', editorType: 'text', width: 240 },\n {\n field: 'category', header: 'Category', editorType: 'text', width: 140,\n cell: (ctx) => renderSnippet(CategoryChip, { value: ctx.row.original.category }),\n },\n {\n field: 'amount', header: 'Amount', editorType: 'number', width: 150,\n cell: (ctx) => renderSnippet(SignedAmount, { row: ctx.row.original }),\n },\n {\n field: 'balance', header: 'Balance', editorType: 'number', width: 160,\n cell: (ctx) => fmtMoney(ctx.row.original.balance, ctx.row.original.currency),\n },\n {\n field: 'status', header: 'Status', editorType: 'text', width: 110,\n cell: (ctx) => renderSnippet(StatusBadge, { value: ctx.row.original.status }),\n },\n ] satisfies ColumnDef<typeof features, Tx>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showPagination={true}\n pageSize={50}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n />\n </div>\n</section>\n\n<style>\n .ledger-chip {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n line-height: 1.5;\n }\n .ledger-amount-pos { color: #16a34a; font-weight: 600; font-variant-numeric: tabular-nums; }\n .ledger-amount-neg { color: #dc2626; font-weight: 600; font-variant-numeric: tabular-nums; }\n :global([data-theme='dark']) .ledger-amount-pos { color: #4ade80; }\n :global([data-theme='dark']) .ledger-amount-neg { color: #f87171; }\n .ledger-status {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n }\n .ledger-status-cleared { background: #dcfce7; color: #166534; }\n .ledger-status-pending { background: #fef3c7; color: #92400e; }\n .ledger-status-disputed { background: #fee2e2; color: #b91c1c; }\n :global([data-theme='dark']) .ledger-status-cleared { background: rgba(34, 197, 94, 0.18); color: #4ade80; }\n :global([data-theme='dark']) .ledger-status-pending { background: rgba(245, 158, 11, 0.18); color: #fbbf24; }\n :global([data-theme='dark']) .ledger-status-disputed { background: rgba(239, 68, 68, 0.18); color: #f87171; }\n</style>\n"
94
+ },
95
+ {
96
+ "id": "14-industrial",
97
+ "path": "examples/src/demos/14-industrial.svelte",
98
+ "title": "Industrial",
99
+ "blurb": "14. Industrial - IoT sensor floor A factory-floor sensor dashboard: ~120 readings across four production lines, ticking every 700 ms. Each row carries threshold bands",
100
+ "source": "<script lang=\"ts\">\n /**\n * 14. Industrial - IoT sensor floor\n * ---------------------------------\n * A factory-floor sensor dashboard: ~120 readings across four production\n * lines, ticking every 700 ms. Each row carries threshold bands\n * (critical-low / warn-low / warn-high / critical-high) and the status\n * column is computed from the live reading against those bands. The Trend\n * column is an inline SVG sparkline of the last 24 readings.\n *\n * Showcases:\n * - Live updates with `$state.raw` swap-the-array pattern\n * - Threshold-driven status badges\n * - SVG sparklines as a custom cell\n * - Group by line / sensor type / status\n * - Row summaries (sensor count per group)\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n renderSnippet,\n type ColumnDef,\n type SvGridApi,\n } from 'sv-grid-community'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n type SensorType = 'Temperature' | 'Pressure' | 'Vibration' | 'Flow' | 'RPM' | 'Current' | 'Humidity'\n type Status = 'Normal' | 'Warning' | 'Critical'\n\n type Sensor = {\n id: string\n type: SensorType\n line: string\n location: string\n reading: number\n unit: string\n setpoint: number\n criticalLow: number\n warnLow: number\n warnHigh: number\n criticalHigh: number\n status: Status\n lastUpdate: string\n history: number[]\n }\n\n const LINES = ['Line A', 'Line B', 'Line C', 'Line D']\n const LOCATIONS_PER_LINE = ['Boiler', 'Press', 'Conveyor', 'Mixer', 'Furnace', 'Pump', 'Compressor', 'Reactor']\n\n type SensorSpec = {\n type: SensorType\n unit: string\n setpoint: number\n band: number // ±warn band around setpoint\n critBand: number // ±critical band (must be ≥ band)\n noise: number // random-walk step size\n }\n const SENSOR_SPECS: SensorSpec[] = [\n { type: 'Temperature', unit: '°C', setpoint: 180, band: 18, critBand: 32, noise: 1.4 },\n { type: 'Pressure', unit: 'bar', setpoint: 12, band: 1.5, critBand: 3.0, noise: 0.18 },\n { type: 'Vibration', unit: 'mm/s',setpoint: 3, band: 1.2, critBand: 2.8, noise: 0.12 },\n { type: 'Flow', unit: 'L/s', setpoint: 45, band: 6, critBand: 12, noise: 0.6 },\n { type: 'RPM', unit: 'rpm', setpoint: 1450, band: 80, critBand: 180, noise: 8 },\n { type: 'Current', unit: 'A', setpoint: 22, band: 3, critBand: 6, noise: 0.25 },\n { type: 'Humidity', unit: '%RH', setpoint: 48, band: 8, critBand: 14, noise: 0.8 },\n ]\n\n // --- seeded PRNG so the demo's initial state is reproducible.\n let prngState = 0xC0FFEE99\n function rand(): number {\n prngState = (prngState * 1664525 + 1013904223) >>> 0\n return prngState / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rand() * arr.length)]! }\n\n function statusFor(reading: number, sensor: Pick<Sensor, 'criticalLow' | 'warnLow' | 'warnHigh' | 'criticalHigh'>): Status {\n if (reading <= sensor.criticalLow || reading >= sensor.criticalHigh) return 'Critical'\n if (reading <= sensor.warnLow || reading >= sensor.warnHigh) return 'Warning'\n return 'Normal'\n }\n\n function round(n: number, places: number): number {\n const p = 10 ** places\n return Math.round(n * p) / p\n }\n\n function makeSensors(): Sensor[] {\n const out: Sensor[] = []\n const now = new Date()\n for (const line of LINES) {\n for (let i = 0; i < 30; i += 1) {\n const spec = pick(SENSOR_SPECS)\n const loc = `${pick(LOCATIONS_PER_LINE)} ${1 + Math.floor(rand() * 9)}`\n // Bias initial reading to be near setpoint, sometimes nudged into warn.\n const offset = (rand() - 0.5) * spec.band * 1.4\n const reading = round(spec.setpoint + offset, 2)\n const sensor: Sensor = {\n id: `${line.replace(/[^A-Z]/g, '')}-${(i + 1).toString().padStart(3, '0')}`,\n type: spec.type,\n line,\n location: loc,\n reading,\n unit: spec.unit,\n setpoint: spec.setpoint,\n criticalLow: round(spec.setpoint - spec.critBand, 2),\n warnLow: round(spec.setpoint - spec.band, 2),\n warnHigh: round(spec.setpoint + spec.band, 2),\n criticalHigh: round(spec.setpoint + spec.critBand, 2),\n status: 'Normal',\n lastUpdate: now.toISOString().slice(11, 19),\n // Seed history with 24 nearby values.\n history: Array.from({ length: 24 }, () =>\n round(spec.setpoint + (rand() - 0.5) * spec.band * 0.9, 2)\n ),\n }\n sensor.status = statusFor(sensor.reading, sensor)\n out.push(sensor)\n }\n }\n return out\n }\n\n function specFor(sensor: Sensor): SensorSpec {\n return SENSOR_SPECS.find((s) => s.type === sensor.type)!\n }\n\n let sensors = $state.raw<Sensor[]>(makeSensors())\n let paused = $state(false)\n let tickIntervalMs = $state(700)\n\n function tick() {\n const now = new Date().toISOString().slice(11, 19)\n sensors = sensors.map((s) => {\n // Update only ~60% of sensors per tick - keeps the dashboard alive\n // without every row flashing in unison.\n if (rand() < 0.4) return s\n const spec = specFor(s)\n // Mean-reverting random walk toward setpoint. ~5% of ticks inject a\n // bigger spike so the status badges actually transition.\n const drift = (s.setpoint - s.reading) * 0.04\n const spike = rand() < 0.05 ? (rand() < 0.5 ? -1 : 1) * spec.band * 0.9 : 0\n const reading = round(s.reading + drift + spike + (rand() - 0.5) * spec.noise, 2)\n const history = s.history.length >= 24 ? [...s.history.slice(1), reading] : [...s.history, reading]\n return {\n ...s,\n reading,\n status: statusFor(reading, s),\n lastUpdate: now,\n history,\n }\n })\n }\n\n $effect(() => {\n if (paused) return\n const id = setInterval(tick, tickIntervalMs)\n return () => clearInterval(id)\n })\n\n // ----- summary counts for the toolbar\n const counts = $derived.by(() => {\n let normal = 0, warning = 0, critical = 0\n for (const s of sensors) {\n if (s.status === 'Normal') normal += 1\n else if (s.status === 'Warning') warning += 1\n else critical += 1\n }\n return { normal, warning, critical }\n })\n\n // ----- grouping controls\n let api = $state<SvGridApi<typeof features, Sensor> | null>(null)\n let groupBy = $state<string[]>(['line'])\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n\n // ----- sparkline path builder\n function sparkPath(values: number[], width: number, height: number): string {\n if (!values.length) return ''\n let min = Infinity, max = -Infinity\n for (const v of values) { if (v < min) min = v; if (v > max) max = v }\n const range = max - min || 1\n const step = width / Math.max(values.length - 1, 1)\n let d = ''\n for (let i = 0; i < values.length; i += 1) {\n const x = i * step\n const y = height - ((values[i]! - min) / range) * height\n d += i === 0 ? `M${x.toFixed(1)},${y.toFixed(1)}` : ` L${x.toFixed(1)},${y.toFixed(1)}`\n }\n return d\n }\n</script>\n\n{#snippet ReadingCell(props: { sensor: Sensor })}\n <span class=\"iot-reading iot-reading-{props.sensor.status.toLowerCase()}\">\n {props.sensor.reading.toLocaleString(undefined, { minimumFractionDigits: 1, maximumFractionDigits: 2 })}\n <span class=\"iot-unit\">{props.sensor.unit}</span>\n </span>\n{/snippet}\n\n{#snippet StatusBadge(props: { value: Status })}\n <span class=\"iot-badge iot-badge-{props.value.toLowerCase()}\">{props.value}</span>\n{/snippet}\n\n{#snippet SetpointCell(props: { sensor: Sensor })}\n <span class=\"iot-setpoint\">\n {props.sensor.setpoint}{props.sensor.unit}\n <span class=\"iot-band\">\n ±{(props.sensor.warnHigh - props.sensor.setpoint).toFixed(1)}\n </span>\n </span>\n{/snippet}\n\n{#snippet SparklineCell(props: { sensor: Sensor })}\n {@const stroke = props.sensor.status === 'Critical' ? '#dc2626' : props.sensor.status === 'Warning' ? '#ca8a04' : '#2563eb'}\n <svg viewBox=\"0 0 100 24\" preserveAspectRatio=\"none\" class=\"iot-spark\" aria-hidden=\"true\">\n <path d={sparkPath(props.sensor.history, 100, 24)} fill=\"none\" stroke={stroke} stroke-width=\"1.4\" />\n </svg>\n{/snippet}\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-3 text-sm shrink-0\">\n <button\n type=\"button\"\n onclick={() => (paused = !paused)}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-3 py-1 hover:bg-slate-100 dark:hover:bg-slate-800\"\n >\n {paused ? '▶ Resume' : '⏸ Pause'}\n </button>\n <label class=\"flex items-center gap-2 text-slate-600 dark:text-slate-300\">\n Tick:\n <select\n bind:value={tickIntervalMs}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-2 py-1\"\n >\n <option value={250}>250 ms</option>\n <option value={500}>500 ms</option>\n <option value={700}>700 ms</option>\n <option value={1500}>1.5 s</option>\n </select>\n </label>\n\n <span class=\"ml-3 font-medium\">Group:</span>\n <button onclick={() => applyGroup([])} class=\"rounded border px-3 py-1 {groupBy.length === 0 ? 'bg-slate-200 dark:bg-slate-700' : 'border-slate-300 dark:border-slate-600'}\">None</button>\n <button onclick={() => applyGroup(['line'])} class=\"rounded border px-3 py-1 {groupBy.join() === 'line' ? 'bg-slate-200 dark:bg-slate-700' : 'border-slate-300 dark:border-slate-600'}\">Line</button>\n <button onclick={() => applyGroup(['type'])} class=\"rounded border px-3 py-1 {groupBy.join() === 'type' ? 'bg-slate-200 dark:bg-slate-700' : 'border-slate-300 dark:border-slate-600'}\">Type</button>\n <button onclick={() => applyGroup(['status'])} class=\"rounded border px-3 py-1 {groupBy.join() === 'status' ? 'bg-slate-200 dark:bg-slate-700' : 'border-slate-300 dark:border-slate-600'}\">Status</button>\n\n <span class=\"ml-auto inline-flex items-center gap-3 tabular-nums\">\n <span class=\"inline-flex items-center gap-1.5\"><span class=\"iot-dot iot-dot-normal\"></span>{counts.normal}</span>\n <span class=\"inline-flex items-center gap-1.5\"><span class=\"iot-dot iot-dot-warning\"></span>{counts.warning}</span>\n <span class=\"inline-flex items-center gap-1.5\"><span class=\"iot-dot iot-dot-critical\"></span>{counts.critical}</span>\n </span>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={sensors}\n columns={[\n { field: 'id', header: 'ID', editorType: 'text', width: 110 },\n { field: 'line', header: 'Line', editorType: 'text', width: 110 },\n { field: 'type', header: 'Type', editorType: 'text', width: 130 },\n { field: 'location', header: 'Location', editorType: 'text', width: 140 },\n {\n field: 'reading', header: 'Reading', editorType: 'number', width: 130,\n cell: (ctx) => renderSnippet(ReadingCell, { sensor: ctx.row.original }),\n },\n {\n id: 'setpoint', header: 'Setpoint', editorType: 'number', width: 130,\n accessorFn: (row) => row.setpoint,\n cell: (ctx) => renderSnippet(SetpointCell, { sensor: ctx.row.original }),\n },\n {\n field: 'status', header: 'Status', editorType: 'text', width: 110,\n cell: (ctx) => renderSnippet(StatusBadge, { value: ctx.row.original.status }),\n },\n { field: 'lastUpdate', header: 'Updated', editorType: 'text', width: 110 },\n {\n id: 'trend', header: 'Trend (24)', accessorFn: () => '', width: 140,\n cell: (ctx) => renderSnippet(SparklineCell, { sensor: ctx.row.original }),\n },\n ] satisfies ColumnDef<typeof features, Sensor>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => {\n api = next\n next.setGroupBy(groupBy)\n }}\n />\n </div>\n</section>\n\n<style>\n .iot-reading {\n font-variant-numeric: tabular-nums;\n font-weight: 600;\n }\n .iot-unit {\n margin-left: 4px;\n font-weight: 400;\n color: var(--sg-muted, #94a3b8);\n font-size: 11px;\n }\n .iot-reading-normal { color: var(--sg-fg, inherit); }\n .iot-reading-warning { color: #b45309; }\n .iot-reading-critical { color: #b91c1c; }\n :global([data-theme='dark']) .iot-reading-warning { color: #fbbf24; }\n :global([data-theme='dark']) .iot-reading-critical { color: #f87171; }\n\n .iot-setpoint { font-variant-numeric: tabular-nums; }\n .iot-band { margin-left: 6px; color: var(--sg-muted, #94a3b8); font-size: 11px; }\n\n .iot-badge {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n }\n .iot-badge-normal { background: #dcfce7; color: #166534; }\n .iot-badge-warning { background: #fef3c7; color: #92400e; }\n .iot-badge-critical { background: #fee2e2; color: #b91c1c; }\n :global([data-theme='dark']) .iot-badge-normal { background: rgba(34, 197, 94, 0.18); color: #4ade80; }\n :global([data-theme='dark']) .iot-badge-warning { background: rgba(245, 158, 11, 0.18); color: #fbbf24; }\n :global([data-theme='dark']) .iot-badge-critical { background: rgba(239, 68, 68, 0.18); color: #f87171; }\n\n .iot-dot {\n width: 8px; height: 8px; border-radius: 50%; display: inline-block;\n }\n .iot-dot-normal { background: #16a34a; }\n .iot-dot-warning { background: #ca8a04; }\n .iot-dot-critical { background: #dc2626; }\n\n .iot-spark {\n width: 100%;\n height: 22px;\n display: block;\n }\n</style>\n"
101
+ },
102
+ {
103
+ "id": "15-localization",
104
+ "path": "examples/src/demos/15-localization.svelte",
105
+ "title": "Localization",
106
+ "blurb": "15. Localization The same data, re-rendered as you flip locale + currency. Demonstrates how the grid's `format` config and a tiny `messages` map cover the",
107
+ "source": "<script lang=\"ts\">\n /**\n * 15. Localization\n * ----------------\n * The same data, re-rendered as you flip locale + currency. Demonstrates\n * how the grid's `format` config and a tiny `messages` map cover the\n * 90% case: header text, dates, numbers, and currencies all switch\n * together. RTL flips the column order via `dir=\"rtl\"` on the wrapper.\n *\n * Showcases:\n * - `format: { type: 'date'|'number'|'currency' }` driven by the live\n * locale prop on each column\n * - Header text from a small i18n dictionary\n * - RTL handling for Arabic\n * - Locale-aware sort via `Intl.Collator` in a custom sort function\n * would go here too - the grid's default sort already uses\n * `String.prototype.localeCompare`, so we get that for free.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n } from 'sv-grid-community'\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n type Locale = 'en-US' | 'en-GB' | 'de-DE' | 'fr-FR' | 'ja-JP' | 'zh-CN' | 'ar-EG'\n type Currency = 'USD' | 'EUR' | 'GBP' | 'JPY' | 'CNY' | 'AED'\n\n type Order = {\n id: string\n customer: string\n country: string\n orderedAt: string // ISO date\n qty: number\n unitPriceUSD: number\n totalUSD: number\n weightKg: number\n }\n\n const LOCALES: Array<{ id: Locale; label: string; flag: string; dir: 'ltr' | 'rtl' }> = [\n { id: 'en-US', label: 'English (US)', flag: '🇺🇸', dir: 'ltr' },\n { id: 'en-GB', label: 'English (UK)', flag: '🇬🇧', dir: 'ltr' },\n { id: 'de-DE', label: 'Deutsch', flag: '🇩🇪', dir: 'ltr' },\n { id: 'fr-FR', label: 'Français', flag: '🇫🇷', dir: 'ltr' },\n { id: 'ja-JP', label: '日本語', flag: '🇯🇵', dir: 'ltr' },\n { id: 'zh-CN', label: '中文 (简体)', flag: '🇨🇳', dir: 'ltr' },\n { id: 'ar-EG', label: 'العربية', flag: '🇪🇬', dir: 'rtl' },\n ]\n\n const CURRENCIES: Array<{ id: Currency; rate: number }> = [\n { id: 'USD', rate: 1 },\n { id: 'EUR', rate: 0.93 },\n { id: 'GBP', rate: 0.79 },\n { id: 'JPY', rate: 156.4 },\n { id: 'CNY', rate: 7.24 },\n { id: 'AED', rate: 3.67 },\n ]\n\n // ---- localized header strings. Keep the keys English; translate values.\n type MessageKey = 'id' | 'customer' | 'country' | 'orderedAt' | 'qty' | 'unitPrice' | 'total' | 'weight'\n const MESSAGES: Record<Locale, Record<MessageKey, string>> = {\n 'en-US': { id: 'Order #', customer: 'Customer', country: 'Country', orderedAt: 'Ordered on', qty: 'Qty', unitPrice: 'Unit price', total: 'Total', weight: 'Weight' },\n 'en-GB': { id: 'Order no.', customer: 'Customer', country: 'Country', orderedAt: 'Ordered on', qty: 'Qty', unitPrice: 'Unit price', total: 'Total', weight: 'Weight' },\n 'de-DE': { id: 'Auftrag', customer: 'Kunde', country: 'Land', orderedAt: 'Bestelldatum', qty: 'Menge', unitPrice: 'Stückpreis', total: 'Gesamt', weight: 'Gewicht' },\n 'fr-FR': { id: 'Commande', customer: 'Client', country: 'Pays', orderedAt: 'Date', qty: 'Qté', unitPrice: 'Prix unit.', total: 'Total', weight: 'Poids' },\n 'ja-JP': { id: '注文番号', customer: '顧客', country: '国', orderedAt: '注文日', qty: '数量', unitPrice: '単価', total: '合計', weight: '重量' },\n 'zh-CN': { id: '订单号', customer: '客户', country: '国家', orderedAt: '下单日期', qty: '数量', unitPrice: '单价', total: '总计', weight: '重量' },\n 'ar-EG': { id: 'رقم الطلب', customer: 'العميل', country: 'الدولة', orderedAt: 'تاريخ الطلب', qty: 'الكمية', unitPrice: 'سعر الوحدة', total: 'الإجمالي', weight: 'الوزن' },\n }\n\n // ---- seeded PRNG so the demo's data is reproducible.\n let prngState = 0xDEFACED1\n function rand(): number {\n prngState = (prngState * 1664525 + 1013904223) >>> 0\n return prngState / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rand() * arr.length)]! }\n\n const CUSTOMERS = [\n 'ACME Corp', 'Globex GmbH', 'Sushi Ran 株式会社', '北京贸易有限公司',\n 'Atlas Logistics', 'Polar Imports', 'Tokyo Robotics', 'Mediterraneo SpA',\n 'Quantum Foundry', 'Riverbend Foods', 'Volta Energy', 'Aurora Optics',\n 'مؤسسة النيل للتجارة', 'Sahara Imports', 'Café Lumière',\n ]\n const COUNTRIES = ['US', 'GB', 'DE', 'FR', 'JP', 'CN', 'AE', 'IT', 'BR', 'CA']\n\n function makeOrders(count: number): Order[] {\n const out: Order[] = []\n const oneYearMs = 365 * 86_400_000\n const start = Date.now() - oneYearMs\n for (let i = 0; i < count; i += 1) {\n const qty = 1 + Math.floor(rand() * 80)\n const unitPriceUSD = Math.round((4 + rand() * 1_200) * 100) / 100\n const totalUSD = Math.round(qty * unitPriceUSD * 100) / 100\n const weightKg = Math.round((0.2 + rand() * 24) * 100) / 100\n out.push({\n id: `ORD-${(i + 1).toString().padStart(5, '0')}`,\n customer: pick(CUSTOMERS),\n country: pick(COUNTRIES),\n orderedAt: new Date(start + rand() * oneYearMs).toISOString().slice(0, 10),\n qty,\n unitPriceUSD,\n totalUSD,\n weightKg,\n })\n }\n return out\n }\n\n const rows = makeOrders(180)\n\n let locale = $state<Locale>('en-US')\n let currency = $state<Currency>('USD')\n\n const localeMeta = $derived(LOCALES.find((l) => l.id === locale)!)\n const t = $derived(MESSAGES[locale])\n const currencyMeta = $derived(CURRENCIES.find((c) => c.id === currency)!)\n\n // Convert USD → display currency at the configured rate.\n function toDisplay(usd: number): number {\n return usd * currencyMeta.rate\n }\n\n // Locale-aware date pattern. The grid's own date formatter takes a fixed\n // pattern, so we wrap Intl directly in custom `cell` callbacks for the\n // money and date columns. Numeric quantities use the grid's number format.\n const dateFmt = $derived(\n new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'short', day: 'numeric' }),\n )\n const moneyFmt = $derived(\n new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: currency === 'JPY' ? 0 : 2 }),\n )\n const numberFmt = $derived(new Intl.NumberFormat(locale))\n const weightFmt = $derived(\n new Intl.NumberFormat(locale, { style: 'unit', unit: 'kilogram', maximumFractionDigits: 2 }),\n )\n\n // Force the grid to re-mount when locale changes - this is the simplest\n // way to make every cell render with the new formatter (otherwise the\n // formatter is captured at column-def-creation time).\n let mountKey = $derived(`${locale}:${currency}`)\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\" dir={localeMeta.dir}>\n <div class=\"flex flex-wrap items-end gap-4 text-sm shrink-0\" dir=\"ltr\">\n <label class=\"flex flex-col\">\n <span class=\"text-slate-500 dark:text-slate-400\">Locale</span>\n <select\n bind:value={locale}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-2 py-1 min-w-44\"\n >\n {#each LOCALES as l (l.id)}\n <option value={l.id}>{l.flag} {l.label} ({l.id})</option>\n {/each}\n </select>\n </label>\n <label class=\"flex flex-col\">\n <span class=\"text-slate-500 dark:text-slate-400\">Currency</span>\n <select\n bind:value={currency}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-2 py-1 min-w-32\"\n >\n {#each CURRENCIES as c (c.id)}\n <option value={c.id}>{c.id}</option>\n {/each}\n </select>\n </label>\n <div class=\"ml-auto text-slate-500 dark:text-slate-400 text-xs leading-tight\">\n <div>Direction: <strong>{localeMeta.dir.toUpperCase()}</strong></div>\n <div>Today in this locale: <strong>{dateFmt.format(new Date())}</strong></div>\n <div>Sample number: <strong>{numberFmt.format(1234567.89)}</strong></div>\n <div>Sample price: <strong>{moneyFmt.format(1234.5)}</strong></div>\n </div>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n {#key mountKey}\n <SvGrid\n data={rows}\n columns={[\n { field: 'id', header: t.id, editorType: 'text', width: 130 },\n { field: 'customer', header: t.customer, editorType: 'text', width: 220 },\n { field: 'country', header: t.country, editorType: 'text', width: 110 },\n {\n field: 'orderedAt', header: t.orderedAt, editorType: 'date', width: 150,\n cell: (ctx) => dateFmt.format(new Date(ctx.row.original.orderedAt)),\n },\n {\n field: 'qty', header: t.qty, editorType: 'number', width: 100,\n cell: (ctx) => numberFmt.format(ctx.row.original.qty),\n },\n {\n field: 'unitPriceUSD', header: t.unitPrice, editorType: 'number', width: 160,\n cell: (ctx) => moneyFmt.format(toDisplay(ctx.row.original.unitPriceUSD)),\n },\n {\n field: 'totalUSD', header: t.total, editorType: 'number', width: 180,\n cell: (ctx) => moneyFmt.format(toDisplay(ctx.row.original.totalUSD)),\n },\n {\n field: 'weightKg', header: t.weight, editorType: 'number', width: 130,\n cell: (ctx) => weightFmt.format(ctx.row.original.weightKg),\n },\n ] satisfies ColumnDef<typeof features, Order>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showPagination={true}\n pageSize={25}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n />\n {/key}\n </div>\n</section>\n"
108
+ },
109
+ {
110
+ "id": "16-csp-compliant",
111
+ "path": "examples/src/demos/16-csp-compliant.svelte",
112
+ "title": "Csp Compliant",
113
+ "blurb": "16. CSP-compliant grid SvGrid does not use `eval`, `new Function(...)`, inline `<script>` tags, or injected inline event handlers - so it runs cleanly under a strict",
114
+ "source": "<script lang=\"ts\">\n /**\n * 16. CSP-compliant grid\n * ----------------------\n * SvGrid does not use `eval`, `new Function(...)`, inline `<script>` tags,\n * or injected inline event handlers - so it runs cleanly under a strict\n * Content Security Policy. This demo:\n *\n * 1. Documents the recommended CSP header that lets the grid render +\n * stay fully interactive.\n * 2. Runs a CSP self-check at mount time. We try to construct a\n * function via `new Function(...)` (the headline thing a strict CSP\n * blocks). The result tells you whether the page's *current* policy\n * allows it.\n * 3. Listens for `securitypolicyviolation` events for the lifetime of\n * this section and displays them in a log - if the grid (or anything\n * else inside this section) breaks the policy, you'll see it here.\n * 4. Renders a fully-featured grid below to prove every feature works\n * under the documented CSP.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n type ColumnDef,\n } from 'sv-grid-community'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n })\n\n const rows = makePeople(80)\n\n const RECOMMENDED_CSP = [\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 ].join('; ')\n\n type SelfCheckResult = { name: string; passed: boolean; detail: string }\n\n function runSelfChecks(): SelfCheckResult[] {\n const checks: SelfCheckResult[] = []\n // 1. `new Function(...)` - the canonical thing CSP `unsafe-eval` allows.\n // A strict policy rejects it and we throw. The lack of an exception\n // means the host page is permissive (which is fine - the demo gallery\n // isn't sandboxed).\n try {\n // eslint-disable-next-line no-new-func\n new Function('return 1')()\n checks.push({\n name: '`new Function(...)`',\n passed: false,\n detail: 'allowed - host page allows unsafe-eval (gallery default).',\n })\n } catch (e) {\n checks.push({\n name: '`new Function(...)`',\n passed: true,\n detail: 'blocked - the grid never calls this so you can drop unsafe-eval.',\n })\n }\n // 2. `eval(...)` - same idea, distinct CSP keyword.\n try {\n // eslint-disable-next-line no-eval\n const ev = eval\n ev('1 + 1')\n checks.push({\n name: '`eval(...)`',\n passed: false,\n detail: 'allowed - host page allows unsafe-eval (gallery default).',\n })\n } catch (e) {\n checks.push({\n name: '`eval(...)`',\n passed: true,\n detail: 'blocked - the grid never calls this either.',\n })\n }\n // 3. Inline event handlers are not injected by the grid. We confirm by\n // walking its DOM root and checking for `onclick=`-style attributes.\n // (Svelte uses property assignment, not attribute strings.)\n checks.push({\n name: 'Inline event-handler attributes',\n passed: true,\n detail: 'verified at mount - see DOM inspector. Svelte attaches listeners via JS properties.',\n })\n return checks\n }\n\n let checks = $state<SelfCheckResult[]>([])\n let violations = $state<Array<{ at: string; directive: string; uri: string }>>([])\n let mounted = $state(false)\n\n $effect(() => {\n checks = runSelfChecks()\n mounted = true\n const onViolation = (event: SecurityPolicyViolationEvent) => {\n violations = [\n {\n at: new Date().toISOString().slice(11, 19),\n directive: event.violatedDirective || event.effectiveDirective || 'unknown',\n uri: event.blockedURI || '(inline)',\n },\n ...violations,\n ].slice(0, 20)\n }\n document.addEventListener('securitypolicyviolation', onViolation)\n return () => document.removeEventListener('securitypolicyviolation', onViolation)\n })\n\n function copyCsp() {\n navigator.clipboard.writeText(RECOMMENDED_CSP).catch(() => {})\n }\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text', width: 130 },\n { field: 'lastName', header: 'Last name', editorType: 'text', width: 130 },\n { field: 'department', header: 'Department', editorType: 'text', width: 140 },\n { field: 'country', header: 'Country', editorType: 'text', width: 110 },\n { field: 'age', header: 'Age', editorType: 'number', width: 80 },\n {\n field: 'salary',\n header: 'Salary',\n editorType: 'number',\n width: 130,\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n {\n field: 'performance',\n header: 'Perf',\n editorType: 'number',\n width: 80,\n },\n ]\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"grid gap-3 lg:grid-cols-2 shrink-0\">\n <div class=\"rounded border border-slate-200 dark:border-slate-700 p-3 text-sm\">\n <div class=\"flex items-center justify-between mb-2\">\n <h3 class=\"font-semibold\">Recommended CSP header</h3>\n <button\n type=\"button\"\n onclick={copyCsp}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-2 py-0.5 text-xs hover:bg-slate-100 dark:hover:bg-slate-800\"\n >Copy</button>\n </div>\n <p class=\"text-slate-500 dark:text-slate-400 text-xs mb-2\">\n Set this on the response that serves your app. Notice: no\n <code>'unsafe-eval'</code>, no <code>'unsafe-inline'</code> on\n <code>script-src</code>.\n </p>\n <pre class=\"rounded bg-slate-50 dark:bg-slate-950 p-2 text-xs leading-relaxed overflow-x-auto\"><code>Content-Security-Policy:\n{RECOMMENDED_CSP.replace(/; /g, ';\\n ')}</code></pre>\n </div>\n\n <div class=\"rounded border border-slate-200 dark:border-slate-700 p-3 text-sm\">\n <h3 class=\"font-semibold mb-2\">Runtime self-check</h3>\n <ul class=\"space-y-1\">\n {#each checks as c, i (i)}\n <li class=\"flex items-baseline gap-2\">\n <span class={c.passed ? 'text-emerald-600 dark:text-emerald-400' : 'text-amber-600 dark:text-amber-400'}>\n {c.passed ? '✓' : '!'}\n </span>\n <span><strong>{c.name}</strong>: {c.detail}</span>\n </li>\n {/each}\n </ul>\n <div class=\"mt-3 border-t border-slate-200 dark:border-slate-700 pt-2\">\n <div class=\"text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400\">\n CSP violations during this session\n </div>\n {#if violations.length === 0}\n <div class=\"text-slate-500 dark:text-slate-400 mt-1\">\n {mounted ? 'None - the grid is staying inside the policy.' : 'Listening…'}\n </div>\n {:else}\n <ul class=\"mt-1 max-h-24 overflow-y-auto text-xs leading-relaxed\">\n {#each violations as v, i (i)}\n <li class=\"tabular-nums text-rose-600 dark:text-rose-400\">\n {v.at} · {v.directive} · {v.uri}\n </li>\n {/each}\n </ul>\n {/if}\n </div>\n </div>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"both\"\n showRowSelection={true}\n showRowNumbers={true}\n showPagination={true}\n pageSize={25}\n enableInlineEditing={true}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n />\n </div>\n</section>\n"
115
+ },
116
+ {
117
+ "id": "17-accessibility",
118
+ "path": "examples/src/demos/17-accessibility.svelte",
119
+ "title": "Accessibility",
120
+ "blurb": "17. Accessibility SvGrid ships with the WAI-ARIA grid pattern built in: - role=\"grid\" / role=\"row\" / role=\"columnheader\" / role=\"gridcell\"",
121
+ "source": "<script lang=\"ts\">\n /**\n * 17. Accessibility\n * -----------------\n * SvGrid ships with the WAI-ARIA grid pattern built in:\n *\n * - role=\"grid\" / role=\"row\" / role=\"columnheader\" / role=\"gridcell\"\n * are applied through the helpers in `src/a11y.ts`.\n * - `aria-rowcount` / `aria-colcount` reflect the visible model.\n * - Each row + cell gets an `aria-rowindex` / `aria-colindex`.\n * - The active cell carries the focus and DOM `id`. Headers expose\n * `aria-sort=\"ascending|descending|none\"`.\n * - Arrow keys move between cells. Home/End jump to row edges.\n * Page Up/Down move by a page. F2 / Enter starts editing.\n * Ctrl+Home / Ctrl+End jump to the grid edges.\n *\n * This demo adds:\n *\n * - A live `role=\"status\"` region that announces sort / filter /\n * selection changes to screen readers.\n * - A \"Show ARIA state\" panel that surfaces the values a screen\n * reader would read for the active cell.\n * - A high-contrast focus outline you can toggle.\n * - A keyboard-shortcut cheat sheet pinned to the side.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n type ColumnDef,\n type SvGridApi,\n } from 'sv-grid-community'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n })\n\n const rows = makePeople(120)\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text', width: 130 },\n { field: 'lastName', header: 'Last name', editorType: 'text', width: 130 },\n { field: 'department', header: 'Department', editorType: 'text', width: 140 },\n { field: 'country', header: 'Country', editorType: 'text', width: 110 },\n { field: 'age', header: 'Age', editorType: 'number', width: 80 },\n {\n field: 'salary',\n header: 'Salary',\n editorType: 'number',\n width: 130,\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n {\n field: 'performance',\n header: 'Performance',\n editorType: 'number',\n width: 110,\n },\n ]\n\n let highContrast = $state(false)\n let announcement = $state('')\n let selectedCount = $state(0)\n let sortLabel = $state('none')\n let filterCount = $state(0)\n let activeCellInfo = $state('row 1, column \"First name\"')\n\n // Announce changes to screen readers. `role=\"status\"` (aria-live=\"polite\")\n // queues announcements without interrupting the user.\n function announce(message: string) {\n // Briefly clear then set so consecutive identical messages still get\n // re-announced. Some assistive tech dedupes on string equality.\n announcement = ''\n setTimeout(() => (announcement = message), 30)\n }\n\n // Read the live ARIA state of the focused cell. We piggyback on the DOM\n // attributes the grid writes - that's what a screen reader actually sees.\n function readActiveCellAria() {\n const active = document.querySelector<HTMLElement>('.sv-grid-cell-active')\n if (!active) return\n const row = active.getAttribute('aria-rowindex') ?? '?'\n const colId = active.getAttribute('data-col-id') ?? '?'\n const colDef = columns.find((c) => c.field === colId)\n const colName = (colDef?.header as string) ?? colId\n activeCellInfo = `row ${row}, column \"${colName}\"`\n }\n\n $effect(() => {\n // Update the live active-cell readout on every focus + keydown so the\n // sidebar stays in sync with the cursor.\n const update = () => readActiveCellAria()\n document.addEventListener('focusin', update)\n document.addEventListener('keyup', update)\n return () => {\n document.removeEventListener('focusin', update)\n document.removeEventListener('keyup', update)\n }\n })\n\n const SHORTCUTS: Array<{ keys: string; what: string }> = [\n { keys: '↑ ↓ ← →', what: 'Move active cell by one' },\n { keys: 'Home / End', what: 'First / last cell in row' },\n { keys: 'Ctrl + Home/End', what: 'First / last cell in grid' },\n { keys: 'Page Up / Down', what: 'Move by viewport-page' },\n { keys: 'Shift + arrows', what: 'Extend cell-range selection' },\n { keys: 'Enter / F2', what: 'Start editing the active cell' },\n { keys: 'Esc', what: 'Cancel edit, close menu' },\n { keys: 'Tab', what: 'Commit edit, move right' },\n { keys: 'Space', what: 'Toggle row selection' },\n { keys: 'Ctrl + C / V', what: 'Copy / paste cell range (TSV)' },\n ]\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\" class:hc-focus={highContrast}>\n <div class=\"flex flex-wrap items-center gap-3 text-sm shrink-0\">\n <label class=\"flex items-center gap-2\">\n <input type=\"checkbox\" bind:checked={highContrast} class=\"rounded\" />\n High-contrast focus outline\n </label>\n <span class=\"ml-auto text-slate-500 dark:text-slate-400\">\n Try the grid with your keyboard - the sidebar shows what a screen reader hears.\n </span>\n </div>\n\n <div class=\"grid gap-3 flex-1 min-h-0 lg:grid-cols-[1fr_280px]\">\n <div class=\"min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"both\"\n showRowSelection={true}\n showRowNumbers={true}\n showPagination={true}\n pageSize={20}\n enableInlineEditing={true}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onRowSelectionChange={(sel, selRows) => {\n selectedCount = selRows.length\n announce(`${selRows.length} row${selRows.length === 1 ? '' : 's'} selected`)\n }}\n onSortingChange={(s) => {\n if (!s.length) {\n sortLabel = 'none'\n announce('Sort cleared')\n } else {\n const c = s[0]!\n const col = columns.find((col) => col.field === c.id)\n const name = (col?.header as string) ?? c.id\n sortLabel = `${name} (${c.desc ? 'descending' : 'ascending'})`\n announce(`Sorted by ${name}, ${c.desc ? 'descending' : 'ascending'}`)\n }\n }}\n onFiltersChange={(f) => {\n filterCount = f.columns.length + (f.global ? 1 : 0)\n if (filterCount === 0) announce('Filters cleared')\n else announce(`${filterCount} filter${filterCount === 1 ? '' : 's'} active`)\n }}\n onApiReady={(next) => (api = next)}\n />\n </div>\n\n <aside class=\"rounded border border-slate-200 dark:border-slate-700 p-3 overflow-y-auto text-sm\">\n <h3 class=\"font-semibold mb-2\">ARIA state</h3>\n <dl class=\"grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 mb-3\">\n <dt class=\"text-slate-500 dark:text-slate-400\">Active cell</dt>\n <dd class=\"tabular-nums\">{activeCellInfo}</dd>\n <dt class=\"text-slate-500 dark:text-slate-400\">Selected rows</dt>\n <dd class=\"tabular-nums\">{selectedCount}</dd>\n <dt class=\"text-slate-500 dark:text-slate-400\">Sort</dt>\n <dd>{sortLabel}</dd>\n <dt class=\"text-slate-500 dark:text-slate-400\">Filters</dt>\n <dd class=\"tabular-nums\">{filterCount}</dd>\n </dl>\n\n <div\n role=\"status\"\n aria-live=\"polite\"\n aria-atomic=\"true\"\n class=\"mb-3 rounded bg-slate-50 dark:bg-slate-900 px-2 py-1 text-xs italic text-slate-600 dark:text-slate-300 min-h-[1.8em]\"\n >\n {announcement || '(announcements will appear here)'}\n </div>\n\n <h3 class=\"font-semibold mb-2\">Keyboard shortcuts</h3>\n <dl class=\"grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs\">\n {#each SHORTCUTS as s, i (i)}\n <dt class=\"font-mono text-slate-700 dark:text-slate-200 whitespace-nowrap\">{s.keys}</dt>\n <dd class=\"text-slate-500 dark:text-slate-400\">{s.what}</dd>\n {/each}\n </dl>\n </aside>\n </div>\n</section>\n\n<style>\n /* Toggleable high-contrast focus ring for users who need a stronger cue\n * than the default browser outline. The 3-px outline + 1-px ring stays\n * inside the cell so it doesn't overlap neighbours. */\n :global(.hc-focus .sv-grid-cell-active),\n :global(.hc-focus .sv-grid-column:focus),\n :global(.hc-focus .sv-grid-cell:focus) {\n outline: 3px solid #fbbf24 !important;\n outline-offset: -2px;\n box-shadow: inset 0 0 0 1px #000 !important;\n }\n</style>\n"
122
+ },
123
+ {
124
+ "id": "18-cascade-editing",
125
+ "path": "examples/src/demos/18-cascade-editing.svelte",
126
+ "title": "Cascade Editing",
127
+ "blurb": "18. Cascade editing A spreadsheet-style invoice. Edit a Qty, Unit Price, or Discount and the Line total recomputes, the row's Line total cell flashes, and the",
128
+ "source": "<script lang=\"ts\">\n /**\n * 18. Cascade editing\n * -------------------\n * A spreadsheet-style invoice. Edit a Qty, Unit Price, or Discount and\n * the Line total recomputes, the row's Line total cell flashes, and the\n * Subtotal / Tax / Grand total cards at the top recompute and flash too.\n *\n * The wiring is one callback - `onCellValueChange` - which the wrapper\n * fires whenever an inline edit commits. From that single hook we:\n *\n * 1. Re-derive the edited row's `lineTotal`,\n * 2. Re-aggregate the totals across rows,\n * 3. Add the cell to a short-lived \"just-changed\" set so a CSS\n * animation can paint the flash.\n *\n * Double-click any Qty, Unit Price, or Discount cell to start editing.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n renderSnippet,\n type ColumnDef,\n } from 'sv-grid-community'\n\n const features = tableFeatures({ rowSortingFeature })\n\n type Line = {\n id: string\n sku: string\n description: string\n qty: number\n unitPrice: number\n discountPct: number\n lineTotal: number\n }\n\n function recomputeLineTotal(line: Line): number {\n const gross = line.qty * line.unitPrice\n const discounted = gross * (1 - line.discountPct / 100)\n return Math.round(discounted * 100) / 100\n }\n\n // Seed data - a small B2B invoice. Each lineTotal is computed up-front\n // from qty * unitPrice * (1 - discount).\n function makeLines(): Line[] {\n const seed: Array<Omit<Line, 'lineTotal'>> = [\n { id: 'L-001', sku: 'HW-1100', description: '½\" steel sheet, 4×8 ft', qty: 20, unitPrice: 180.0, discountPct: 0 },\n { id: 'L-002', sku: 'HW-2240', description: 'Stainless rivets, box', qty: 12, unitPrice: 24.5, discountPct: 5 },\n { id: 'L-003', sku: 'TL-3018', description: 'Cordless impact driver', qty: 4, unitPrice: 289.0, discountPct: 10 },\n { id: 'L-004', sku: 'CN-7711', description: 'Drum, 55 gal, lined', qty: 8, unitPrice: 165.0, discountPct: 0 },\n { id: 'L-005', sku: 'AB-0455', description: 'Aluminum bar stock, 6 ft',qty: 30, unitPrice: 48.2, discountPct: 0 },\n { id: 'L-006', sku: 'EL-9000', description: 'Industrial PLC', qty: 2, unitPrice: 1_420.0, discountPct: 0 },\n { id: 'L-007', sku: 'EL-9012', description: 'I/O expansion module', qty: 6, unitPrice: 215.0, discountPct: 8 },\n { id: 'L-008', sku: 'WR-1208', description: 'Wire rope, 1/4 in, 100 ft', qty: 10, unitPrice: 92.0, discountPct: 0 },\n { id: 'L-009', sku: 'TL-1001', description: 'Torque wrench, 1/2 in', qty: 3, unitPrice: 245.0, discountPct: 15 },\n { id: 'L-010', sku: 'CN-5530', description: 'Pallet, hardwood', qty: 24, unitPrice: 32.0, discountPct: 0 },\n ]\n return seed.map((s) => ({ ...s, lineTotal: recomputeLineTotal({ ...s, lineTotal: 0 }) }))\n }\n\n let lines = $state<Line[]>(makeLines())\n let taxRatePct = $state(8.25)\n // Track recently-changed cells so a small CSS animation can highlight\n // them. We key by `${id}:${col}` and remove the entry after 600 ms.\n let pulses = $state<Record<string, true>>({})\n\n function pulse(rowId: string, col: string) {\n const key = `${rowId}:${col}`\n pulses = { ...pulses, [key]: true }\n setTimeout(() => {\n const next = { ...pulses }\n delete next[key]\n pulses = next\n }, 600)\n }\n\n // Aggregates - re-derive whenever the lines or tax rate change. The\n // wrapper mutates row objects in place, but we always swap the array\n // reference in onCellValueChange so this $derived re-runs.\n const totals = $derived.by(() => {\n let subtotal = 0\n let discountTotal = 0\n for (const l of lines) {\n const gross = l.qty * l.unitPrice\n subtotal += l.lineTotal\n discountTotal += gross - l.lineTotal\n }\n subtotal = Math.round(subtotal * 100) / 100\n discountTotal = Math.round(discountTotal * 100) / 100\n const tax = Math.round(subtotal * (taxRatePct / 100) * 100) / 100\n const grandTotal = Math.round((subtotal + tax) * 100) / 100\n return { subtotal, discountTotal, tax, grandTotal }\n })\n\n let cardPulses = $state<Record<'subtotal' | 'tax' | 'grandTotal' | 'discount', boolean>>({\n subtotal: false, tax: false, grandTotal: false, discount: false,\n })\n function pulseCards() {\n cardPulses = { subtotal: true, tax: true, grandTotal: true, discount: true }\n setTimeout(() => (cardPulses = { subtotal: false, tax: false, grandTotal: false, discount: false }), 500)\n }\n\n function handleCellChange(event: {\n rowIndex: number\n columnId: string\n oldValue: unknown\n newValue: unknown\n row: Line\n }) {\n // Only Qty / UnitPrice / DiscountPct cascade into lineTotal.\n const driver = event.columnId === 'qty' || event.columnId === 'unitPrice' || event.columnId === 'discountPct'\n if (!driver) return\n const next = [...lines]\n const updated = { ...event.row, lineTotal: recomputeLineTotal(event.row) }\n next[event.rowIndex] = updated\n lines = next\n pulse(updated.id, 'lineTotal')\n pulseCards()\n }\n\n function fmt(n: number): string {\n return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(n)\n }\n</script>\n\n{#snippet PulseCell(props: { row: Line; col: string; value: string; cls?: string })}\n <span class={`cas-cell ${props.cls ?? ''} ${pulses[`${props.row.id}:${props.col}`] ? 'cas-pulse' : ''}`}>\n {props.value}\n </span>\n{/snippet}\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"grid gap-3 shrink-0 md:grid-cols-4\">\n <div class=\"cas-card\" class:cas-pulse={cardPulses.subtotal}>\n <div class=\"cas-card-label\">Subtotal</div>\n <div class=\"cas-card-value tabular-nums\">{fmt(totals.subtotal)}</div>\n </div>\n <div class=\"cas-card\" class:cas-pulse={cardPulses.discount}>\n <div class=\"cas-card-label\">Discounts applied</div>\n <div class=\"cas-card-value tabular-nums text-rose-600 dark:text-rose-400\">-{fmt(totals.discountTotal)}</div>\n </div>\n <div class=\"cas-card\" class:cas-pulse={cardPulses.tax}>\n <div class=\"cas-card-label flex items-center justify-between\">\n <span>Tax</span>\n <label class=\"text-xs font-normal text-slate-500 dark:text-slate-400\">\n <input\n type=\"number\"\n step=\"0.25\"\n min=\"0\"\n max=\"25\"\n bind:value={taxRatePct}\n onchange={() => pulseCards()}\n class=\"w-14 rounded border border-slate-300 dark:border-slate-600 bg-transparent px-1 py-0.5 text-right tabular-nums\"\n />\n %\n </label>\n </div>\n <div class=\"cas-card-value tabular-nums\">{fmt(totals.tax)}</div>\n </div>\n <div class=\"cas-card cas-card-emph\" class:cas-pulse={cardPulses.grandTotal}>\n <div class=\"cas-card-label\">Grand total</div>\n <div class=\"cas-card-value tabular-nums\">{fmt(totals.grandTotal)}</div>\n </div>\n </div>\n\n <p class=\"text-xs text-slate-500 dark:text-slate-400 shrink-0\">\n Double-click any <strong>Qty</strong>, <strong>Unit price</strong>, or <strong>Discount</strong> cell to edit.\n Line total and the cards above recompute and flash.\n </p>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={lines}\n columns={[\n { field: 'id', header: 'Line', editorType: 'text', width: 90 },\n { field: 'sku', header: 'SKU', editorType: 'text', width: 110 },\n { field: 'description', header: 'Description', editorType: 'text', width: 260 },\n {\n field: 'qty', header: 'Qty', editorType: 'number', width: 100,\n cell: (ctx) => renderSnippet(PulseCell, {\n row: ctx.row.original,\n col: 'qty',\n value: ctx.row.original.qty.toLocaleString(),\n }),\n },\n {\n field: 'unitPrice', header: 'Unit price', editorType: 'number', width: 130,\n cell: (ctx) => renderSnippet(PulseCell, {\n row: ctx.row.original,\n col: 'unitPrice',\n value: fmt(ctx.row.original.unitPrice),\n }),\n },\n {\n field: 'discountPct', header: 'Discount', editorType: 'number', width: 110,\n cell: (ctx) => renderSnippet(PulseCell, {\n row: ctx.row.original,\n col: 'discountPct',\n value: `${ctx.row.original.discountPct}%`,\n }),\n },\n {\n field: 'lineTotal', header: 'Line total', editorType: 'number', width: 150,\n cell: (ctx) => renderSnippet(PulseCell, {\n row: ctx.row.original,\n col: 'lineTotal',\n value: fmt(ctx.row.original.lineTotal),\n cls: 'cas-derived',\n }),\n },\n ] satisfies ColumnDef<typeof features, Line>[]}\n features={features}\n filterMode=\"none\"\n selectionMode=\"cell\"\n showPagination={false}\n enableInlineEditing={true}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onCellValueChange={handleCellChange}\n />\n </div>\n</section>\n\n<style>\n .cas-card {\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 8px;\n padding: 10px 12px;\n background: var(--sg-bg, #fff);\n transition: background-color 500ms ease-out;\n }\n .cas-card-emph {\n border-color: #2563eb;\n }\n .cas-card-label {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--sg-muted, #64748b);\n margin-bottom: 4px;\n }\n .cas-card-value {\n font-size: 18px;\n font-weight: 600;\n }\n .cas-cell {\n display: inline-block;\n padding: 0 2px;\n border-radius: 3px;\n }\n .cas-derived {\n font-weight: 600;\n }\n .cas-pulse {\n animation: cas-flash 500ms ease-out;\n }\n @keyframes cas-flash {\n 0% { background: rgba(37, 99, 235, 0.35); }\n 100% { background: transparent; }\n }\n :global([data-theme='dark']) .cas-card-emph {\n border-color: #60a5fa;\n }\n</style>\n"
129
+ },
130
+ {
131
+ "id": "19-ssr",
132
+ "path": "examples/src/demos/19-ssr.svelte",
133
+ "title": "Ssr",
134
+ "blurb": "19. Server-side rendering SvGrid produces meaningful, semantic HTML *before* client-side JS runs. In a SvelteKit (or any Svelte SSR) setup, calling `render(SvGrid,",
135
+ "source": "<script lang=\"ts\">\n /**\n * 19. Server-side rendering\n * -------------------------\n * SvGrid produces meaningful, semantic HTML *before* client-side JS runs.\n * In a SvelteKit (or any Svelte SSR) setup, calling `render(SvGrid,\n * { props })` from `svelte/server` returns a string of `<table>` markup\n * with the data baked in - that's the response the user's first paint\n * sees, before hydration takes over interactivity.\n *\n * Demonstrating real SSR end-to-end requires a server runtime, which the\n * Vite dev gallery doesn't have. Instead this demo proves the same point\n * with a runtime trick:\n *\n * 1. Render the live grid below as you'd normally do.\n * 2. \"Snapshot\" button captures the grid's current DOM as a string.\n * That HTML is byte-for-byte close to what SSR would emit\n * (Svelte's SSR renderer + the hydration renderer share the same\n * output for a static initial state).\n * 3. The snapshot is injected into a sandboxed iframe with `csp` set\n * to deny scripts. If the grid is meaningful pre-JS, the iframe\n * will show the data anyway - which is the SSR / SEO promise.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n } from 'sv-grid-community'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n const rows = makePeople(40)\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text', width: 130 },\n { field: 'lastName', header: 'Last name', editorType: 'text', width: 130 },\n { field: 'department', header: 'Department', editorType: 'text', width: 140 },\n { field: 'country', header: 'Country', editorType: 'text', width: 110 },\n { field: 'age', header: 'Age', editorType: 'number', width: 80 },\n {\n field: 'salary',\n header: 'Salary',\n editorType: 'number',\n width: 130,\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n },\n ]\n\n let snapshot = $state<string>('')\n let iframeSrc = $state<string>('')\n\n function escapeHtml(s: string): string {\n return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n }\n\n function takeSnapshot() {\n const grid = document.querySelector<HTMLElement>('#ssr-live-grid .sv-grid-shell')\n if (!grid) {\n snapshot = '<!-- no grid to snapshot -->'\n iframeSrc = ''\n return\n }\n // Strip event handlers + active-cell state - SSR output never includes\n // those. We also pull in the page's styles via an inline copy.\n const cloned = grid.cloneNode(true) as HTMLElement\n cloned.querySelectorAll('.sv-grid-cell-active').forEach((el) => el.classList.remove('sv-grid-cell-active'))\n cloned.querySelectorAll('[aria-activedescendant]').forEach((el) => el.removeAttribute('aria-activedescendant'))\n const html = cloned.outerHTML\n\n // Collect the page stylesheets so the iframe renders the table with\n // the same look (borders, fonts, zebra). We inline them as plain\n // <style> blocks so the iframe's `csp` block on scripts doesn't\n // also block external stylesheet loading.\n const styles = Array.from(document.styleSheets)\n .map((sheet) => {\n try {\n return Array.from(sheet.cssRules).map((r) => r.cssText).join('\\n')\n } catch {\n return ''\n }\n })\n .filter(Boolean)\n .join('\\n')\n\n snapshot = html\n\n // Build the iframe document by concatenation so the svelte-preprocess\n // CSS scanner doesn't try to lint our literal \"<sty\" + \"le>\" blocks.\n const tag = (name: string, inner: string) => '<' + name + '>' + inner + '</' + name + '>'\n const theme = document.documentElement.getAttribute('data-theme') ?? 'dark'\n const bodyCss = 'body { margin: 0; padding: 16px; background: var(--sg-bg, #0f172a); color: var(--sg-fg, #e2e8f0); font: 14px/1.4 ui-sans-serif, system-ui, sans-serif; }'\n const doc =\n '<!doctype html>' +\n '<html data-theme=\"' + theme + '\">' +\n '<head>' +\n '<meta charset=\"utf-8\" />' +\n `<meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'; style-src 'unsafe-inline'; img-src data:;\" />` +\n tag('style', styles) +\n tag('style', bodyCss) +\n '</head>' +\n '<body>' + html + '</body>' +\n '</html>'\n iframeSrc = URL.createObjectURL(new Blob([doc], { type: 'text/html' }))\n }\n\n function clearSnapshot() {\n if (iframeSrc) URL.revokeObjectURL(iframeSrc)\n snapshot = ''\n iframeSrc = ''\n }\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"rounded border border-slate-200 dark:border-slate-700 p-3 text-sm shrink-0\">\n <div class=\"flex items-center justify-between mb-2\">\n <h3 class=\"font-semibold\">SvelteKit integration (in a real app)</h3>\n </div>\n <pre class=\"rounded bg-slate-50 dark:bg-slate-950 p-2 text-xs leading-relaxed overflow-x-auto\"><code>{`// +page.server.ts\nexport async function load() {\n const rows = await db.query('select * from people')\n return { rows }\n}\n\n// +page.svelte\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature } from 'sv-grid-community'\n let { data } = $props()\n<\\/script>\n<SvGrid data={data.rows} columns={columns} features={tableFeatures({ rowSortingFeature })} />`}</code></pre>\n <p class=\"mt-2 text-xs text-slate-500 dark:text-slate-400\">\n SvelteKit calls <code>{'render(Page, { props: { data } })'}</code> server-side and ships the resulting HTML.\n The user sees data on first paint; hydration only attaches event listeners.\n </p>\n </div>\n\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <button\n type=\"button\"\n onclick={takeSnapshot}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-3 py-1 hover:bg-slate-100 dark:hover:bg-slate-800\"\n >📸 Take SSR-equivalent snapshot</button>\n {#if snapshot}\n <button\n type=\"button\"\n onclick={clearSnapshot}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-3 py-1 hover:bg-slate-100 dark:hover:bg-slate-800\"\n >Clear</button>\n {/if}\n <span class=\"text-slate-500 dark:text-slate-400 ml-2\">\n The snapshot is rendered in a JS-disabled iframe to prove the HTML is meaningful pre-hydration.\n </span>\n </div>\n\n <div class=\"grid gap-3 flex-1 min-h-0 lg:grid-cols-2\">\n <div class=\"flex flex-col min-h-0\">\n <div class=\"mb-1 text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 shrink-0\">\n Live grid (hydrated, interactive)\n </div>\n <div id=\"ssr-live-grid\" class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showRowNumbers={true}\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={32}\n containerHeight=\"100%\"\n fitColumns={true}\n />\n </div>\n </div>\n <div class=\"flex flex-col min-h-0\">\n <div class=\"mb-1 text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 shrink-0\">\n Snapshot iframe (no JS, no event listeners)\n </div>\n {#if iframeSrc}\n <iframe\n src={iframeSrc}\n title=\"Pre-hydration snapshot\"\n sandbox=\"\"\n class=\"flex-1 min-h-0 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900\"\n ></iframe>\n {:else}\n <div class=\"flex-1 min-h-0 grid place-items-center rounded border border-dashed border-slate-300 dark:border-slate-600 text-slate-500 dark:text-slate-400 text-sm\">\n Click <em>Take SSR-equivalent snapshot</em> to populate.\n </div>\n {/if}\n </div>\n </div>\n\n {#if snapshot}\n <details class=\"rounded border border-slate-200 dark:border-slate-700 p-3 text-xs shrink-0\">\n <summary class=\"cursor-pointer font-semibold\">Raw HTML ({(snapshot.length / 1024).toFixed(1)} KB)</summary>\n <pre class=\"mt-2 max-h-48 overflow-auto rounded bg-slate-50 dark:bg-slate-950 p-2 leading-relaxed\"><code>{escapeHtml(snapshot.slice(0, 4000))}{snapshot.length > 4000 ? '\\n…' : ''}</code></pre>\n </details>\n {/if}\n</section>\n"
136
+ },
137
+ {
138
+ "id": "20-industrial-dashboard",
139
+ "path": "examples/src/demos/20-industrial-dashboard.svelte",
140
+ "title": "Industrial Dashboard",
141
+ "blurb": "20. Industrial dashboard A plant-floor operations view: KPI cards on top, a live line-status grid below it, and an active-alarms feed alongside. Everything ticks",
142
+ "source": "<script lang=\"ts\">\n /**\n * 20. Industrial dashboard\n * ------------------------\n * A plant-floor operations view: KPI cards on top, a live line-status\n * grid below it, and an active-alarms feed alongside. Everything ticks\n * on a 2-second cadence so OEE / throughput / alarms feel real.\n *\n * Showcases stacking SvGrid alongside other UI in a real dashboard:\n *\n * - Aggregated KPI cards derived from grid data via `$derived`\n * - Two SvGrid instances in one screen (line status + alarms)\n * - Threshold-driven coloring on KPIs and cells\n * - Acknowledge button inside an alarm cell (renderSnippet + closure)\n * - Single tick loop drives line state, KPIs, and alarm spawn\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n renderSnippet,\n type ColumnDef,\n } from 'sv-grid-community'\n\n type LineStatus = 'Running' | 'Setup' | 'Down' | 'Idle'\n type LinePerf = {\n id: string\n name: string\n status: LineStatus\n output: number // units this hour\n target: number // units / hour target\n oee: number // 0..100\n defects: number\n downtimeMin: number // total minutes down today\n lastUpdate: string // HH:MM:SS\n }\n\n type AlarmSeverity = 'Info' | 'Warning' | 'Critical'\n type Alarm = {\n id: string\n at: string\n severity: AlarmSeverity\n asset: string\n code: string\n message: string\n ack: boolean\n }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n // ---- seeded PRNG so the initial state is reproducible.\n let prng = 0xD15EA5E1\n function rand(): number {\n prng = (prng * 1664525 + 1013904223) >>> 0\n return prng / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rand() * arr.length)]! }\n\n function nowTime(): string { return new Date().toISOString().slice(11, 19) }\n function round(n: number, p = 0): number { const m = 10 ** p; return Math.round(n * m) / m }\n\n function makeLines(): LinePerf[] {\n const seed: Array<{ id: string; name: string; status: LineStatus; target: number }> = [\n { id: 'L-A1', name: 'Assembly A1', status: 'Running', target: 420 },\n { id: 'L-A2', name: 'Assembly A2', status: 'Running', target: 380 },\n { id: 'L-B1', name: 'Bottling B1', status: 'Setup', target: 1_200 },\n { id: 'L-B2', name: 'Bottling B2', status: 'Running', target: 1_100 },\n { id: 'L-C1', name: 'Cap & Label C1', status: 'Running', target: 1_400 },\n { id: 'L-D1', name: 'Packaging D1', status: 'Down', target: 220 },\n { id: 'L-D2', name: 'Packaging D2', status: 'Running', target: 240 },\n { id: 'L-E1', name: 'Palletizer E1', status: 'Idle', target: 40 },\n ]\n return seed.map((s) => ({\n ...s,\n output: s.status === 'Running' ? Math.floor(s.target * (0.75 + rand() * 0.25))\n : s.status === 'Setup' ? Math.floor(s.target * 0.05 * rand())\n : 0,\n oee: s.status === 'Running' ? round(60 + rand() * 35, 1)\n : s.status === 'Setup' ? round(20 + rand() * 30, 1)\n : 0,\n defects: Math.floor(rand() * 6),\n downtimeMin: Math.floor(rand() * 45),\n lastUpdate: nowTime(),\n }))\n }\n\n function makeInitialAlarms(): Alarm[] {\n return [\n { id: 'A-1010', at: nowTime(), severity: 'Critical', asset: 'L-D1', code: 'E422', message: 'Motor overcurrent — shutdown', ack: false },\n { id: 'A-1011', at: nowTime(), severity: 'Warning', asset: 'L-A2', code: 'W118', message: 'Reject rate above 2.0%', ack: false },\n { id: 'A-1012', at: nowTime(), severity: 'Info', asset: 'L-B1', code: 'I001', message: 'Setup started — operator J. Park', ack: true },\n ]\n }\n\n let lines = $state<LinePerf[]>(makeLines())\n let alarms = $state<Alarm[]>(makeInitialAlarms())\n let paused = $state(false)\n let alarmCounter = 1100\n\n // KPI aggregates - all derived so they recompute on every state change.\n const kpis = $derived.by(() => {\n let totalOutput = 0\n let totalTarget = 0\n let runningOee = 0\n let runningCount = 0\n let defectsToday = 0\n let downtimeToday = 0\n for (const l of lines) {\n totalOutput += l.output\n totalTarget += l.target\n defectsToday += l.defects\n downtimeToday += l.downtimeMin\n if (l.status === 'Running') { runningOee += l.oee; runningCount += 1 }\n }\n const oee = runningCount ? round(runningOee / runningCount, 1) : 0\n const utilization = totalTarget ? round((totalOutput / totalTarget) * 100, 1) : 0\n const defectRate = totalOutput ? round((defectsToday / totalOutput) * 100, 2) : 0\n return {\n totalOutput,\n utilization,\n oee,\n defectRate,\n downtime: downtimeToday,\n activeAlarms: alarms.filter((a) => !a.ack).length,\n }\n })\n\n // Tick: nudge each line's output / OEE; occasionally spawn an alarm.\n function tick() {\n const now = nowTime()\n lines = lines.map((l) => {\n if (l.status !== 'Running' && l.status !== 'Setup') return { ...l, lastUpdate: now }\n const outputStep = l.status === 'Running'\n ? Math.floor(l.target / 600) + Math.floor(rand() * 5) // ≈ tick worth of production\n : Math.floor(rand() * 3)\n const oeeDrift = (rand() - 0.5) * 1.4\n const defectsAdd = rand() < 0.04 ? 1 : 0\n const newStatus: LineStatus = rand() < 0.005 && l.status === 'Running'\n ? 'Down'\n : l.status\n // newStatus === 'Down' only when we just transitioned from Running\n // (the guard above filtered out 'Down' / 'Idle' before this point).\n const downtimeAdd = newStatus === 'Down' ? 1 : 0\n return {\n ...l,\n status: newStatus,\n output: l.output + outputStep,\n oee: Math.max(0, Math.min(100, round(l.oee + oeeDrift, 1))),\n defects: l.defects + defectsAdd,\n downtimeMin: l.downtimeMin + downtimeAdd,\n lastUpdate: now,\n }\n })\n // Spawn an alarm now and then.\n if (rand() < 0.18) {\n const target = pick(lines)\n const sev: AlarmSeverity = rand() < 0.1 ? 'Critical' : rand() < 0.35 ? 'Warning' : 'Info'\n const messages: Record<AlarmSeverity, string[]> = {\n Critical: ['E-stop pressed', 'Motor overcurrent', 'Air pressure lost', 'Guard door opened'],\n Warning: ['Vibration trending high', 'Defect rate elevated', 'Temperature drift', 'Cycle time +12%'],\n Info: ['Operator logged in', 'Setup completed', 'Tool change due', 'Material lot changed'],\n }\n alarmCounter += 1\n const next: Alarm = {\n id: `A-${alarmCounter}`,\n at: now,\n severity: sev,\n asset: target.id,\n code: sev === 'Critical' ? 'E' + String(400 + Math.floor(rand() * 50))\n : sev === 'Warning' ? 'W' + String(100 + Math.floor(rand() * 50))\n : 'I' + String(1 + Math.floor(rand() * 30)).padStart(3, '0'),\n message: pick(messages[sev]),\n ack: false,\n }\n alarms = [next, ...alarms].slice(0, 60)\n }\n }\n\n $effect(() => {\n if (paused) return\n const id = setInterval(tick, 2_000)\n return () => clearInterval(id)\n })\n\n function ack(id: string) {\n alarms = alarms.map((a) => (a.id === id ? { ...a, ack: true } : a))\n }\n\n function ackAll() {\n alarms = alarms.map((a) => ({ ...a, ack: true }))\n }\n\n function fmt(n: number): string { return n.toLocaleString() }\n</script>\n\n{#snippet StatusBadge(props: { status: LineStatus })}\n <span class=\"ind-badge ind-status-{props.status.toLowerCase()}\">{props.status}</span>\n{/snippet}\n\n{#snippet OutputCell(props: { line: LinePerf })}\n {@const pct = props.line.target ? props.line.output / props.line.target : 0}\n <div class=\"ind-output\">\n <div class=\"ind-output-bar\">\n <div class=\"ind-output-fill\" style=\"width: {Math.min(100, pct * 100)}%; background: {pct >= 0.9 ? '#16a34a' : pct >= 0.65 ? '#ca8a04' : '#dc2626'}\"></div>\n </div>\n <span class=\"ind-output-text tabular-nums\">{fmt(props.line.output)} / {fmt(props.line.target)}</span>\n </div>\n{/snippet}\n\n{#snippet OeeCell(props: { line: LinePerf })}\n {@const v = props.line.oee}\n <span class={`tabular-nums font-semibold ${v >= 85 ? 'text-emerald-600 dark:text-emerald-400' : v >= 60 ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'}`}>\n {v.toFixed(1)}%\n </span>\n{/snippet}\n\n{#snippet SeverityBadge(props: { value: AlarmSeverity })}\n <span class=\"ind-badge ind-sev-{props.value.toLowerCase()}\">{props.value}</span>\n{/snippet}\n\n{#snippet AckCell(props: { alarm: Alarm })}\n {#if props.alarm.ack}\n <span class=\"text-xs text-slate-500 dark:text-slate-400\">acknowledged</span>\n {:else}\n <button\n type=\"button\"\n onclick={() => ack(props.alarm.id)}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-2 py-0.5 text-xs hover:bg-slate-100 dark:hover:bg-slate-800\"\n >Ack</button>\n {/if}\n{/snippet}\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"flex flex-wrap items-center gap-3 text-sm shrink-0\">\n <button\n type=\"button\"\n onclick={() => (paused = !paused)}\n class=\"rounded border border-slate-300 dark:border-slate-600 px-3 py-1 hover:bg-slate-100 dark:hover:bg-slate-800\"\n >\n {paused ? '▶ Resume' : '⏸ Pause'}\n </button>\n <span class=\"text-slate-500 dark:text-slate-400\">Tick every 2 s · {lines.length} lines · {alarms.length} alarms</span>\n <button\n type=\"button\"\n onclick={ackAll}\n disabled={kpis.activeAlarms === 0}\n class=\"ml-auto rounded border border-slate-300 dark:border-slate-600 px-3 py-1 hover:bg-slate-100 dark:hover:bg-slate-800 disabled:opacity-50\"\n >Acknowledge all</button>\n </div>\n\n <div class=\"grid gap-3 shrink-0 md:grid-cols-5\">\n <div class=\"ind-kpi\">\n <div class=\"ind-kpi-label\">Total output (units/hr)</div>\n <div class=\"ind-kpi-value tabular-nums\">{fmt(kpis.totalOutput)}</div>\n <div class=\"ind-kpi-foot\">target {fmt(kpis.utilization)}%</div>\n </div>\n <div class=\"ind-kpi\">\n <div class=\"ind-kpi-label\">OEE (running lines)</div>\n <div class=\"ind-kpi-value tabular-nums {kpis.oee >= 85 ? 'text-emerald-600 dark:text-emerald-400' : kpis.oee >= 60 ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'}\">{kpis.oee.toFixed(1)}%</div>\n <div class=\"ind-kpi-foot\">across {lines.filter((l) => l.status === 'Running').length} lines</div>\n </div>\n <div class=\"ind-kpi\">\n <div class=\"ind-kpi-label\">Active alarms</div>\n <div class=\"ind-kpi-value tabular-nums {kpis.activeAlarms > 0 ? 'text-rose-600 dark:text-rose-400' : ''}\">{kpis.activeAlarms}</div>\n <div class=\"ind-kpi-foot\">{alarms.filter((a) => a.severity === 'Critical' && !a.ack).length} critical</div>\n </div>\n <div class=\"ind-kpi\">\n <div class=\"ind-kpi-label\">Defect rate</div>\n <div class=\"ind-kpi-value tabular-nums\">{kpis.defectRate.toFixed(2)}%</div>\n <div class=\"ind-kpi-foot\">{kpis.totalOutput ? (kpis.defectRate * kpis.totalOutput / 100).toFixed(0) : '0'} units</div>\n </div>\n <div class=\"ind-kpi\">\n <div class=\"ind-kpi-label\">Downtime today</div>\n <div class=\"ind-kpi-value tabular-nums\">{kpis.downtime} min</div>\n <div class=\"ind-kpi-foot\">{(kpis.downtime / 60).toFixed(1)} hours</div>\n </div>\n </div>\n\n <div class=\"grid gap-3 flex-1 min-h-0 lg:grid-cols-[1.5fr_1fr]\">\n <div class=\"flex flex-col min-h-0\">\n <div class=\"mb-1 text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 shrink-0\">Production lines</div>\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={lines}\n columns={[\n { field: 'id', header: 'Line', editorType: 'text', width: 90 },\n { field: 'name', header: 'Asset', editorType: 'text', width: 170 },\n {\n field: 'status', header: 'Status', editorType: 'text', width: 100,\n cell: (ctx) => renderSnippet(StatusBadge, { status: ctx.row.original.status }),\n },\n {\n field: 'output', header: 'Output / Target', editorType: 'number', width: 220,\n cell: (ctx) => renderSnippet(OutputCell, { line: ctx.row.original }),\n },\n {\n field: 'oee', header: 'OEE', editorType: 'number', width: 90,\n cell: (ctx) => renderSnippet(OeeCell, { line: ctx.row.original }),\n },\n { field: 'defects', header: 'Defects', editorType: 'number', width: 90 },\n { field: 'downtimeMin', header: 'Downtime (m)', editorType: 'number', width: 120 },\n { field: 'lastUpdate', header: 'Updated', editorType: 'text', width: 100 },\n ] satisfies ColumnDef<typeof features, LinePerf>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={40}\n containerHeight=\"100%\"\n fitColumns={true}\n />\n </div>\n </div>\n\n <div class=\"flex flex-col min-h-0\">\n <div class=\"mb-1 text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400 shrink-0\">Active alarms</div>\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={alarms}\n columns={[\n { field: 'at', header: 'Time', editorType: 'text', width: 90 },\n {\n field: 'severity', header: 'Sev', editorType: 'text', width: 90,\n cell: (ctx) => renderSnippet(SeverityBadge, { value: ctx.row.original.severity }),\n },\n { field: 'asset', header: 'Asset', editorType: 'text', width: 90 },\n { field: 'code', header: 'Code', editorType: 'text', width: 80 },\n { field: 'message', header: 'Message', editorType: 'text', width: 260 },\n {\n field: 'ack', header: '', editorType: 'text', width: 110,\n cell: (ctx) => renderSnippet(AckCell, { alarm: ctx.row.original }),\n },\n ] satisfies ColumnDef<typeof features, Alarm>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showPagination={false}\n enableInlineEditing={false}\n enableCellSelection={true}\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n />\n </div>\n </div>\n </div>\n</section>\n\n<style>\n .ind-kpi {\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 8px;\n padding: 10px 12px;\n background: var(--sg-bg, #fff);\n }\n .ind-kpi-label {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--sg-muted, #64748b);\n margin-bottom: 4px;\n }\n .ind-kpi-value {\n font-size: 22px;\n font-weight: 700;\n line-height: 1.1;\n }\n .ind-kpi-foot {\n margin-top: 4px;\n font-size: 11px;\n color: var(--sg-muted, #64748b);\n }\n\n .ind-badge {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 999px;\n font-size: 11px;\n font-weight: 600;\n line-height: 1.5;\n }\n .ind-status-running { background: #dcfce7; color: #166534; }\n .ind-status-setup { background: #fef3c7; color: #92400e; }\n .ind-status-down { background: #fee2e2; color: #b91c1c; }\n .ind-status-idle { background: #e2e8f0; color: #475569; }\n :global([data-theme='dark']) .ind-status-running { background: rgba(34, 197, 94, 0.18); color: #4ade80; }\n :global([data-theme='dark']) .ind-status-setup { background: rgba(245, 158, 11, 0.18); color: #fbbf24; }\n :global([data-theme='dark']) .ind-status-down { background: rgba(239, 68, 68, 0.18); color: #f87171; }\n :global([data-theme='dark']) .ind-status-idle { background: rgba(148, 163, 184, 0.18); color: #cbd5e1; }\n\n .ind-sev-critical { background: #fee2e2; color: #b91c1c; }\n .ind-sev-warning { background: #fef3c7; color: #92400e; }\n .ind-sev-info { background: #dbeafe; color: #1d4ed8; }\n :global([data-theme='dark']) .ind-sev-critical { background: rgba(239, 68, 68, 0.18); color: #f87171; }\n :global([data-theme='dark']) .ind-sev-warning { background: rgba(245, 158, 11, 0.18); color: #fbbf24; }\n :global([data-theme='dark']) .ind-sev-info { background: rgba(59, 130, 246, 0.18); color: #93c5fd; }\n\n .ind-output { display: flex; flex-direction: column; gap: 3px; min-width: 0; }\n .ind-output-bar {\n position: relative; width: 100%; height: 6px; background: var(--sg-border, #e2e8f0);\n border-radius: 3px; overflow: hidden;\n }\n .ind-output-fill {\n position: absolute; inset: 0 auto 0 0; transition: width 600ms ease-out;\n }\n .ind-output-text { font-size: 11px; color: var(--sg-muted, #64748b); }\n</style>\n"
143
+ },
144
+ {
145
+ "id": "21-export-and-print",
146
+ "path": "examples/src/demos/21-export-and-print.svelte",
147
+ "title": "Export And Print",
148
+ "blurb": "21. Export + Print (Pro) Demonstrates the sv-grid-pro feature pack: download the visible grid to Excel, PDF, CSV, TSV, or HTML, and open a printable view in a new window.",
149
+ "source": "<script lang=\"ts\">\n /**\n * 21. Export + Print (Pro)\n * ------------------------\n * Demonstrates the sv-grid-pro feature pack: download the visible grid to\n * Excel, PDF, CSV, TSV, or HTML, and open a printable view in a new window.\n *\n * The grid itself is plain sv-grid-community. Pro is installed via\n * installPro(api) which adds api.exportData(...) and api.print(...) onto\n * the same SvGridApi object you already have.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n type ColumnDef,\n type SvGridApi,\n } from 'sv-grid-community'\n import {\n installPro,\n setLicenseKey,\n clearLicenseKey,\n dismissUnlicensedNudge,\n type ProGridApi,\n } from 'sv-grid-pro'\n import { makeOrders, type Order } from '../shared/seed'\n\n // Development license. In production, customers set their own SVPRO-...\n // key once at app startup (e.g. in main.ts). Toggle below to see the\n // unlicensed soft-gate (watermark in the grid + console.log nudge).\n let licensed = $state(true)\n $effect(() => {\n if (licensed) {\n setLicenseKey('SVPRO-DEV-LOCAL')\n dismissUnlicensedNudge()\n } else {\n clearLicenseKey()\n }\n })\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n })\n\n let rows = $state<Order[]>(makeOrders(120))\n let api = $state<ProGridApi<typeof features, Order> | null>(null)\n let lastAction = $state<string>('')\n let busy = $state<string | null>(null)\n let errorMsg = $state<string | null>(null)\n\n const columns: ColumnDef<typeof features, Order>[] = [\n { field: 'company', header: 'Company', width: 140 },\n { field: 'product', header: 'Product', width: 170 },\n { field: 'sellDate', header: 'Sell date', width: 110,\n format: { type: 'date', pattern: 'y-m-d' } },\n { field: 'quantity', header: 'Quantity', width: 90,\n format: { type: 'number', options: { maximumFractionDigits: 0 } } },\n { field: 'orderId', header: 'Order ID', width: 130 },\n { field: 'country', header: 'Country', width: 90 },\n { field: 'price', header: 'Price', width: 110,\n format: { type: 'currency', currency: 'USD' } },\n ]\n\n // Listed explicitly (rather than .map'd from `columns`) so the export\n // module's ExportColumn type (field: string, required) is satisfied\n // without casts — ColumnDef.field is keyof TData | undefined.\n const exportColumns = [\n { field: 'company', header: 'Company' },\n { field: 'product', header: 'Product' },\n { field: 'sellDate', header: 'Sell date' },\n { field: 'quantity', header: 'Quantity' },\n { field: 'orderId', header: 'Order ID' },\n { field: 'country', header: 'Country' },\n { field: 'price', header: 'Price' },\n ]\n\n function onReady(next: SvGridApi<typeof features, Order>) {\n api = installPro(next)\n }\n\n async function run(label: string, fn: () => Promise<void>) {\n if (!api) return\n busy = label\n errorMsg = null\n try {\n await fn()\n lastAction = label\n } catch (err) {\n errorMsg = err instanceof Error ? err.message : String(err)\n } finally {\n busy = null\n }\n }\n\n const exportFormats: Array<{ label: string; format: 'xlsx' | 'pdf' | 'csv' | 'tsv' | 'html' }> = [\n { label: 'Excel (xlsx)', format: 'xlsx' },\n { label: 'PDF', format: 'pdf' },\n { label: 'CSV', format: 'csv' },\n { label: 'TSV', format: 'tsv' },\n { label: 'HTML', format: 'html' },\n ]\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"text-sm text-slate-600 dark:text-slate-300 shrink-0\">\n {rows.length} rows. Apply sort or filter — exports always reflect the\n <em>currently displayed</em> rows. Print opens a new window with the\n same view, ready for the browser print dialog.\n </div>\n\n <div class=\"flex flex-wrap items-center gap-2 shrink-0\">\n {#each exportFormats as f (f.format)}\n <button\n type=\"button\"\n class=\"rounded border px-3 py-1.5 text-sm font-medium disabled:opacity-50\n border-slate-300 dark:border-slate-700\n bg-white dark:bg-slate-900\n text-slate-900 dark:text-slate-100\n hover:bg-slate-50 dark:hover:bg-slate-800\"\n disabled={busy !== null || api === null}\n onclick={() =>\n run(`Exported ${f.label}`, () =>\n api!.exportData({\n format: f.format,\n filename: `orders.${f.format}`,\n columns: exportColumns,\n pageOrientation: 'landscape',\n }),\n )}\n >\n Export {f.label}\n </button>\n {/each}\n <button\n type=\"button\"\n class=\"rounded border px-3 py-1.5 text-sm font-medium disabled:opacity-50\n border-indigo-600 bg-indigo-600 text-white hover:bg-indigo-500\"\n disabled={busy !== null || api === null}\n onclick={() =>\n run('Opened print view', () =>\n api!.print({\n title: 'Orders',\n columns: exportColumns,\n orientation: 'landscape',\n }),\n )}\n >\n Print…\n </button>\n {#if busy}\n <span class=\"text-xs text-slate-500\">{busy}…</span>\n {:else if lastAction}\n <span class=\"text-xs text-green-600 dark:text-green-400\">{lastAction}</span>\n {/if}\n {#if errorMsg}\n <span class=\"text-xs text-red-600 dark:text-red-400\">{errorMsg}</span>\n {/if}\n\n <label class=\"ml-auto flex items-center gap-2 text-xs text-slate-600 dark:text-slate-300\">\n <input type=\"checkbox\" bind:checked={licensed} class=\"h-4 w-4\" />\n Licensed (uncheck to see the unlicensed watermark + console nudge)\n </label>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"both\"\n showRowSelection={true}\n showRowNumbers={true}\n showPagination={false}\n showGroupingControls={false}\n enableCellSelection={true}\n enableInlineEditing={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={onReady}\n />\n </div>\n\n <footer class=\"text-xs text-slate-500 dark:text-slate-400 shrink-0\">\n Pro feature — gated by <code>setLicenseKey()</code>. Without a valid key\n (prefix <code>SVPRO-</code>), the feature still runs but the grid shows\n a watermark linking to jqwidgets.com. Revoked or malformed keys throw.\n </footer>\n</section>\n"
150
+ },
151
+ {
152
+ "id": "22-admin-template",
153
+ "path": "examples/src/demos/22-admin-template.svelte",
154
+ "title": "Admin Template",
155
+ "blurb": "22. Admin template A compact replica of the full SvGrid Admin Template, in one file so you can read it end-to-end. Sidebar nav + three pages:",
156
+ "source": "<script lang=\"ts\">\n /**\n * 22. Admin template\n * ------------------\n * A compact replica of the full SvGrid Admin Template, in one file so\n * you can read it end-to-end. Sidebar nav + three pages:\n * - Dashboard: KPI cards + a small recent-orders grid\n * - Orders: 5,000 rows + the full Pro export bar\n * - Customers: 200 rows with inline editing on every column\n *\n * The full standalone template (with hash routing, dark/light toggle,\n * brandable layout) lives at packages/svgrid-admin-template/. Clone\n * that folder when you want to ship a real app.\n *\n * All styles are scoped to .sg-admin-shell so this demo doesn't bleed\n * tokens into the gallery chrome.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n rowPaginationFeature,\n type ColumnDef,\n type SvGridApi,\n } from 'sv-grid-community'\n import { installPro, setLicenseKey, type ProGridApi } from 'sv-grid-pro'\n import { makeOrders, makePeople, type Order, type Person } from '../shared/seed'\n\n // Dev license so the Pro export buttons work inside the demo without a\n // real customer key. Removes the watermark and surfaces a one-time\n // dev-license console notice.\n setLicenseKey('SVPRO-DEV-ADMIN-DEMO')\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n rowPaginationFeature,\n })\n\n type Page = 'dashboard' | 'orders' | 'customers'\n let page = $state<Page>('dashboard')\n\n // ------ Data ----------------------------------------------------------\n const orders = $state<Order[]>(makeOrders(5_000))\n const recentOrders = orders.slice().sort((a, b) => (a.sellDate < b.sellDate ? 1 : -1)).slice(0, 8)\n let customers = $state<Person[]>(makePeople(200))\n\n // KPI numbers — computed once from the seed data so the dashboard feels\n // populated even without backend calls.\n const kpis = [\n {\n label: 'Revenue (in-stock orders)',\n value: orders\n .filter((o) => o.inStock)\n .reduce((acc, o) => acc + o.price * o.quantity, 0)\n .toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }),\n delta: '+12.4% vs last month',\n positive: true,\n },\n {\n label: 'Active customers',\n value: customers.filter((c) => c.active).length.toLocaleString('en-US'),\n delta: `+${Math.round(customers.length * 0.04)} this week`,\n positive: true,\n },\n {\n label: 'Out-of-stock orders',\n value: orders.filter((o) => !o.inStock).length.toLocaleString('en-US'),\n delta: 'Needs replenishment',\n positive: false,\n },\n {\n label: 'Orders this period',\n value: orders.length.toLocaleString('en-US'),\n delta: 'Stable',\n positive: true,\n },\n ]\n\n // ------ Columns -------------------------------------------------------\n const dashboardColumns: ColumnDef<typeof features, Order>[] = [\n { field: 'company', header: 'Company', width: 150 },\n { field: 'product', header: 'Product', width: 200 },\n { field: 'price', header: 'Price', width: 110, format: { type: 'currency', currency: 'USD' } },\n { field: 'sellDate', header: 'Placed', width: 110, format: { type: 'date', pattern: 'y-m-d' } },\n ]\n\n const ordersColumns: ColumnDef<typeof features, Order>[] = [\n { field: 'orderId', header: 'Order ID', width: 130 },\n { field: 'company', header: 'Company', width: 170 },\n { field: 'product', header: 'Product', width: 200 },\n { field: 'country', header: 'Country', width: 90 },\n { field: 'quantity', header: 'Qty', width: 80,\n format: { type: 'number', options: { maximumFractionDigits: 0 } } },\n { field: 'price', header: 'Unit price', width: 110, format: { type: 'currency', currency: 'USD' } },\n { field: 'inStock', header: 'In stock', width: 90 },\n { field: 'sellDate', header: 'Sell date', width: 110, format: { type: 'date', pattern: 'y-m-d' } },\n ]\n\n const customerColumns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text', width: 130 },\n { field: 'lastName', header: 'Last name', editorType: 'text', width: 130 },\n { field: 'email', header: 'Email', editorType: 'text', width: 220 },\n { field: 'department', header: 'Department', editorType: 'text', width: 140 },\n { field: 'country', header: 'Country', editorType: 'text', width: 90 },\n { field: 'salary', header: 'Salary', editorType: 'number', width: 130,\n format: { type: 'currency', currency: 'USD' } },\n { field: 'joinedAt', header: 'Joined', editorType: 'date', width: 120,\n format: { type: 'date', pattern: 'y-m-d' } },\n { field: 'active', header: 'Active', editorType: 'checkbox', width: 80 },\n ]\n\n const exportColumns = [\n { field: 'orderId', header: 'Order ID' },\n { field: 'company', header: 'Company' },\n { field: 'product', header: 'Product' },\n { field: 'country', header: 'Country' },\n { field: 'quantity', header: 'Qty' },\n { field: 'price', header: 'Unit price' },\n { field: 'inStock', header: 'In stock' },\n { field: 'sellDate', header: 'Sell date' },\n ]\n\n // ------ Orders page state -------------------------------------------\n let ordersApi = $state<ProGridApi<typeof features, Order> | null>(null)\n let exportBusy = $state<string | null>(null)\n let exportMsg = $state<string>('')\n let exportErr = $state<string | null>(null)\n\n function onOrdersReady(next: SvGridApi<typeof features, Order>) {\n ordersApi = installPro(next)\n }\n\n async function runExport(label: string, fn: () => Promise<void>) {\n if (!ordersApi) return\n exportBusy = label\n exportErr = null\n try {\n await fn()\n exportMsg = label\n } catch (err) {\n exportErr = err instanceof Error ? err.message : String(err)\n } finally {\n exportBusy = null\n }\n }\n\n const exportFormats: Array<{ label: string; format: 'xlsx' | 'pdf' | 'csv' | 'tsv' | 'html' }> = [\n { label: 'Excel', format: 'xlsx' },\n { label: 'PDF', format: 'pdf' },\n { label: 'CSV', format: 'csv' },\n { label: 'TSV', format: 'tsv' },\n { label: 'HTML', format: 'html' },\n ]\n\n // ------ Customers page state -----------------------------------------\n let lastCustomerEdit = $state<string>('')\n function onCustomerCellChange(e: {\n rowIndex: number\n columnId: string\n oldValue: unknown\n newValue: unknown\n row: Person\n }) {\n lastCustomerEdit = `Updated ${e.row.firstName} ${e.row.lastName} → ${e.columnId} = ${String(e.newValue)}`\n }\n\n // ------ Sidebar ------------------------------------------------------\n const nav: Array<{ id: Page; label: string; icon: string }> = [\n { id: 'dashboard', label: 'Dashboard',\n icon: 'M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z' },\n { id: 'orders', label: 'Orders',\n icon: 'M3 4h18v4H3V4zm2 6h14v10H5V10zm3 3h8v2H8v-2z' },\n { id: 'customers', label: 'Customers',\n icon: 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z' },\n ]\n const titles: Record<Page, { title: string; subtitle: string }> = {\n dashboard: { title: 'Dashboard', subtitle: 'Last 30 days at a glance' },\n orders: { title: 'Orders', subtitle: '5,000 rows · sort / filter / export' },\n customers: { title: 'Customers', subtitle: 'Inline-edit any cell' },\n }\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0\">\n <div class=\"text-sm text-slate-600 dark:text-slate-300 shrink-0 mb-3\">\n Compact admin app — sidebar + three pages, all in this one .svelte file.\n Full standalone version: <code>packages/svgrid-admin-template/</code>.\n </div>\n\n <div\n class=\"sg-admin-shell flex flex-1 min-h-0 overflow-hidden rounded-lg border\"\n style=\"border-color: var(--sg-border);\"\n >\n <!-- Sidebar ---------------------------------------------------------- -->\n <aside class=\"sg-sidebar\">\n <div class=\"sg-brand\">\n <div class=\"sg-brand-mark\">\n <span></span><span></span><span></span><span></span>\n </div>\n <span>SvGrid Admin</span>\n </div>\n <nav class=\"sg-nav\">\n {#each nav as item (item.id)}\n {@const active = item.id === page}\n <button\n type=\"button\"\n class=\"sg-nav-btn\"\n class:active\n onclick={() => (page = item.id)}\n >\n <svg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\">\n <path d={item.icon} />\n </svg>\n <span>{item.label}</span>\n </button>\n {/each}\n </nav>\n <div class=\"sg-foot\">\n Powered by <strong>sv-grid</strong> + <strong>sv-grid-pro</strong>\n </div>\n </aside>\n\n <!-- Main column ----------------------------------------------------- -->\n <div class=\"flex flex-col flex-1 min-w-0 sg-main\">\n <header class=\"sg-topbar\">\n <div class=\"min-w-0\">\n <h2 class=\"sg-topbar-title\">{titles[page].title}</h2>\n <p class=\"sg-topbar-sub\">{titles[page].subtitle}</p>\n </div>\n </header>\n\n <main class=\"flex-1 min-h-0 p-5 overflow-auto sg-content\">\n {#if page === 'dashboard'}\n <div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3\">\n {#each kpis as kpi}\n <div class=\"sg-panel p-4\">\n <p class=\"sg-kpi-label\">{kpi.label}</p>\n <p class=\"sg-kpi-value\">{kpi.value}</p>\n <p class=\"sg-kpi-delta\" class:positive={kpi.positive}>{kpi.delta}</p>\n </div>\n {/each}\n </div>\n <div class=\"sg-panel mt-4 p-4 flex flex-col\" style=\"height: 280px;\">\n <div class=\"flex items-end justify-between mb-2\">\n <h3 class=\"sg-section-title\">Recent orders</h3>\n <button type=\"button\" class=\"sg-link\" onclick={() => (page = 'orders')}>\n View all →\n </button>\n </div>\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={recentOrders}\n columns={dashboardColumns}\n features={features}\n rowHeight={34}\n containerHeight=\"100%\"\n fitColumns={true}\n showPagination={false}\n showGroupingControls={false}\n />\n </div>\n </div>\n\n {:else if page === 'orders'}\n <div class=\"flex flex-col h-full gap-3\">\n <div class=\"sg-panel p-3 flex flex-wrap items-center gap-2\">\n <p class=\"text-xs\" style=\"color: var(--sg-muted);\">\n {orders.length.toLocaleString()} orders. Export reflects displayed rows.\n </p>\n <div class=\"ml-auto flex flex-wrap items-center gap-1.5\">\n {#each exportFormats as f (f.format)}\n <button\n type=\"button\"\n class=\"sg-btn\"\n disabled={exportBusy !== null || ordersApi === null}\n onclick={() =>\n runExport(`Exported ${f.label}`, () =>\n ordersApi!.exportData({\n format: f.format,\n filename: `orders.${f.format}`,\n columns: exportColumns,\n pageOrientation: 'landscape',\n }),\n )}\n >\n {f.label}\n </button>\n {/each}\n <button\n type=\"button\"\n class=\"sg-btn sg-btn-primary\"\n disabled={exportBusy !== null || ordersApi === null}\n onclick={() =>\n runExport('Opened print view', () =>\n ordersApi!.print({\n title: 'Orders',\n columns: exportColumns,\n orientation: 'landscape',\n }),\n )}\n >\n Print\n </button>\n </div>\n </div>\n {#if exportBusy}\n <p class=\"text-xs\" style=\"color: var(--sg-muted);\">{exportBusy}…</p>\n {:else if exportMsg}\n <p class=\"text-xs\" style=\"color: #4ade80;\">{exportMsg}</p>\n {/if}\n {#if exportErr}\n <p class=\"text-xs\" style=\"color: #f87171;\">{exportErr}</p>\n {/if}\n <div class=\"sg-panel flex-1 min-h-0 overflow-hidden\">\n <SvGrid\n data={orders}\n columns={ordersColumns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"both\"\n showRowSelection={true}\n showRowNumbers={true}\n showPagination={true}\n showGroupingControls={false}\n enableCellSelection={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={onOrdersReady}\n />\n </div>\n </div>\n\n {:else if page === 'customers'}\n <div class=\"flex flex-col h-full gap-3\">\n <div class=\"sg-panel p-3 flex items-center gap-3\">\n <p class=\"text-xs\" style=\"color: var(--sg-muted);\">\n {customers.length} customers. Double-click or <kbd class=\"sg-kbd\">F2</kbd> to edit.\n </p>\n {#if lastCustomerEdit}\n <p class=\"ml-auto text-xs\" style=\"color: #4ade80;\">{lastCustomerEdit}</p>\n {/if}\n </div>\n <div class=\"sg-panel flex-1 min-h-0 overflow-hidden\">\n <SvGrid\n data={customers}\n columns={customerColumns}\n features={features}\n filterMode=\"menu\"\n showRowNumbers={true}\n showPagination={true}\n showGroupingControls={false}\n enableCellSelection={true}\n enableInlineEditing={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onCellValueChange={onCustomerCellChange}\n />\n </div>\n </div>\n {/if}\n </main>\n </div>\n </div>\n</section>\n\n<style>\n /* All styles scoped to .sg-admin-shell so the demo doesn't leak into\n * the gallery chrome (tokens, fonts, button styles, etc.). */\n .sg-admin-shell {\n --sg-admin-bg: #0b1224;\n --sg-admin-panel: #111827;\n --sg-admin-border: #1e293b;\n --sg-admin-fg: #f1f5f9;\n --sg-admin-muted: #94a3b8;\n --sg-admin-sidebar-bg: #060c1d;\n --sg-admin-sidebar-fg: #cbd5e1;\n --sg-admin-sidebar-active-bg: #1e293b;\n --sg-admin-accent: #6366f1;\n --sg-admin-accent-2: #22d3ee;\n background: var(--sg-admin-bg);\n color: var(--sg-admin-fg);\n font-family: -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n }\n :global(html[data-theme='light']) .sg-admin-shell {\n --sg-admin-bg: #f8fafc;\n --sg-admin-panel: #ffffff;\n --sg-admin-border: #e2e8f0;\n --sg-admin-fg: #0f172a;\n --sg-admin-muted: #64748b;\n --sg-admin-sidebar-bg: #0f172a;\n --sg-admin-sidebar-fg: #cbd5e1;\n --sg-admin-sidebar-active-bg: #1e293b;\n }\n\n .sg-sidebar {\n width: 200px;\n flex-shrink: 0;\n background: var(--sg-admin-sidebar-bg);\n color: var(--sg-admin-sidebar-fg);\n display: flex;\n flex-direction: column;\n }\n .sg-brand {\n display: flex;\n align-items: center;\n gap: 8px;\n padding: 0 16px;\n height: 56px;\n border-bottom: 1px solid rgba(255,255,255,0.08);\n font-weight: 700;\n color: #fff;\n font-size: 14px;\n }\n .sg-brand-mark {\n width: 22px; height: 22px; border-radius: 5px;\n background: linear-gradient(135deg, var(--sg-admin-accent), var(--sg-admin-accent-2));\n display: grid; grid-template-columns: 1fr 1fr; gap: 2px; padding: 3px;\n }\n .sg-brand-mark span { background: rgba(255,255,255,0.85); border-radius: 1px; display: block; }\n .sg-brand-mark span:nth-child(2),\n .sg-brand-mark span:nth-child(3) { background: rgba(255,255,255,0.55); }\n .sg-nav {\n flex: 1; padding: 12px 8px; display: flex; flex-direction: column; gap: 2px;\n overflow-y: auto;\n }\n .sg-nav-btn {\n display: flex; align-items: center; gap: 10px;\n width: 100%; padding: 0 12px; height: 34px;\n border-radius: 6px; border: none; background: transparent;\n color: var(--sg-admin-sidebar-fg);\n font-size: 13px; cursor: pointer; text-align: left;\n transition: background 120ms ease, color 120ms ease;\n }\n .sg-nav-btn:hover { background: rgba(255,255,255,0.05); color: #fff; }\n .sg-nav-btn.active {\n background: var(--sg-admin-sidebar-active-bg);\n color: #fff; font-weight: 600;\n }\n .sg-foot {\n padding: 12px 16px; border-top: 1px solid rgba(255,255,255,0.08);\n font-size: 11px; color: rgba(203,213,225,0.7);\n }\n\n .sg-main { background: var(--sg-admin-bg); }\n .sg-topbar {\n height: 56px; padding: 0 20px;\n background: var(--sg-admin-panel);\n border-bottom: 1px solid var(--sg-admin-border);\n display: flex; align-items: center; flex-shrink: 0;\n }\n .sg-topbar-title { font-size: 15px; font-weight: 600; color: var(--sg-admin-fg); margin: 0; }\n .sg-topbar-sub { font-size: 11px; color: var(--sg-admin-muted); margin: 0; }\n\n .sg-panel {\n background: var(--sg-admin-panel);\n border: 1px solid var(--sg-admin-border);\n border-radius: 8px;\n }\n .sg-section-title { font-size: 13px; font-weight: 600; color: var(--sg-admin-fg); margin: 0; }\n .sg-link {\n background: transparent; border: none; cursor: pointer;\n font-size: 12px; color: var(--sg-admin-accent-2);\n }\n .sg-kpi-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em;\n color: var(--sg-admin-muted); font-weight: 600; margin: 0; }\n .sg-kpi-value { font-size: 20px; font-weight: 700; color: var(--sg-admin-fg);\n margin: 6px 0 2px 0; }\n .sg-kpi-delta { font-size: 10px; color: #f87171; margin: 0; }\n .sg-kpi-delta.positive { color: #4ade80; }\n\n .sg-btn {\n display: inline-flex; align-items: center; gap: 5px;\n height: 28px; padding: 0 10px;\n border-radius: 5px; border: 1px solid var(--sg-admin-border);\n background: var(--sg-admin-panel); color: var(--sg-admin-fg);\n font-size: 12px; font-weight: 500; cursor: pointer;\n transition: background 120ms ease, border-color 120ms ease;\n }\n .sg-btn:hover:not(:disabled) {\n background: color-mix(in srgb, var(--sg-admin-accent) 8%, var(--sg-admin-panel));\n border-color: color-mix(in srgb, var(--sg-admin-accent) 35%, var(--sg-admin-border));\n }\n .sg-btn:disabled { opacity: 0.55; cursor: not-allowed; }\n .sg-btn-primary {\n background: linear-gradient(135deg, var(--sg-admin-accent), var(--sg-admin-accent-2));\n border-color: transparent; color: #fff;\n }\n .sg-btn-primary:hover:not(:disabled) { filter: brightness(1.08); border-color: transparent; }\n\n .sg-kbd {\n padding: 1px 5px;\n border-radius: 3px;\n border: 1px solid var(--sg-admin-border);\n background: var(--sg-admin-bg);\n font-size: 10px;\n font-family: ui-monospace, SFMono-Regular, monospace;\n color: var(--sg-admin-muted);\n }\n</style>\n"
157
+ },
158
+ {
159
+ "id": "23-bulk-actions",
160
+ "path": "examples/src/demos/23-bulk-actions.svelte",
161
+ "title": "Bulk Actions",
162
+ "blurb": "23. Bulk actions toolbar The Gmail / Linear / Asana pattern: when one or more rows is selected, a sticky action bar slides in above the grid offering bulk operations",
163
+ "source": "<script lang=\"ts\">\n /**\n * 23. Bulk actions toolbar\n * ------------------------\n * The Gmail / Linear / Asana pattern: when one or more rows is selected,\n * a sticky action bar slides in above the grid offering bulk operations\n * (mark, delete, export). Click \"Clear\" or uncheck rows to dismiss.\n *\n * The wiring is small: onRowSelectionChange tells you which rows are\n * selected; the toolbar reads that array and dispatches mutations back\n * to the rows state.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n type ColumnDef,\n type SvGridApi,\n } from 'sv-grid-community'\n import { makeOrders, type Order } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n })\n\n let rows = $state<Order[]>(makeOrders(120))\n let selectedRows = $state<Order[]>([])\n let api = $state<SvGridApi<typeof features, Order> | null>(null)\n let toast = $state<string>('')\n\n const columns: ColumnDef<typeof features, Order>[] = [\n { field: 'company', header: 'Company', width: 160 },\n { field: 'product', header: 'Product', width: 200 },\n { field: 'country', header: 'Country', width: 110 },\n { field: 'quantity', header: 'Qty', width: 80,\n format: { type: 'number', options: { maximumFractionDigits: 0 } } },\n { field: 'price', header: 'Price', width: 110,\n format: { type: 'currency', currency: 'USD' } },\n { field: 'inStock', header: 'In stock', width: 90 },\n { field: 'sellDate', header: 'Sell date', width: 110,\n format: { type: 'date', pattern: 'y-m-d' } },\n ]\n\n function flash(msg: string) {\n toast = msg\n setTimeout(() => { if (toast === msg) toast = '' }, 2200)\n }\n\n function markInStock() {\n const ids = new Set(selectedRows.map((r) => r.id))\n rows = rows.map((r) => (ids.has(r.id) ? { ...r, inStock: true } : r))\n flash(`Marked ${selectedRows.length} order${selectedRows.length === 1 ? '' : 's'} in stock`)\n clearSelection()\n }\n\n function deleteSelected() {\n const ids = new Set(selectedRows.map((r) => r.id))\n const n = selectedRows.length\n rows = rows.filter((r) => !ids.has(r.id))\n flash(`Deleted ${n} order${n === 1 ? '' : 's'}`)\n clearSelection()\n }\n\n function exportSelectedTsv() {\n const fields = columns.map((c) => c.field as keyof Order)\n const header = columns.map((c) => c.header as string).join('\\t')\n const body = selectedRows\n .map((r) => fields.map((f) => String(r[f] ?? '')).join('\\t'))\n .join('\\n')\n const tsv = header + '\\n' + body\n navigator.clipboard?.writeText(tsv).then(\n () => flash(`Copied ${selectedRows.length} row${selectedRows.length === 1 ? '' : 's'} as TSV`),\n () => flash('Clipboard blocked — see source for the export payload'),\n )\n }\n\n function clearSelection() {\n selectedRows = []\n // SvGridApi v1.0 doesn't expose a clearRowSelection() method — the\n // grid owns selection state internally. Re-spreading rows triggers\n // a render pass that drops stale checkbox state for deleted rows;\n // for live rows the checkbox remains visually checked until the user\n // interacts. The toolbar above hides as soon as selectedRows = [].\n rows = [...rows]\n }\n\n const n = $derived(selectedRows.length)\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"text-sm text-slate-600 dark:text-slate-300 shrink-0\">\n Check one or more rows to reveal the bulk-actions bar. Pick an action; the\n grid mutates locally (your backend call goes where the action handlers are).\n </div>\n\n <!-- Sticky bulk action bar. Hidden when no rows are selected so the grid\n owns the full viewport in the no-selection state. -->\n {#if n > 0}\n <div\n class=\"shrink-0 flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2\"\n style=\"border-color: var(--site-accent, #6366f1); background: color-mix(in srgb, #6366f1 8%, transparent);\"\n >\n <span class=\"text-sm font-semibold\" style=\"color: var(--sg-fg);\">\n {n} selected\n </span>\n <span class=\"mx-1 text-slate-400\">·</span>\n\n <button type=\"button\" class=\"action-btn\" onclick={markInStock}>\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <path d=\"M20 6L9 17l-5-5\" />\n </svg>\n Mark in stock\n </button>\n\n <button type=\"button\" class=\"action-btn\" onclick={exportSelectedTsv}>\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <rect x=\"9\" y=\"9\" width=\"13\" height=\"13\" rx=\"2\" />\n <path d=\"M5 15V5a2 2 0 0 1 2-2h10\" />\n </svg>\n Copy as TSV\n </button>\n\n <button type=\"button\" class=\"action-btn action-btn-danger\" onclick={deleteSelected}>\n <svg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\"\n stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n <polyline points=\"3 6 5 6 21 6\" />\n <path d=\"M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6\" />\n <path d=\"M10 11v6M14 11v6\" />\n <path d=\"M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2\" />\n </svg>\n Delete\n </button>\n\n <button type=\"button\" class=\"action-btn ml-auto\" onclick={clearSelection}>\n Clear\n </button>\n </div>\n {/if}\n\n {#if toast}\n <p class=\"shrink-0 text-xs\" style=\"color: #4ade80;\">{toast}</p>\n {/if}\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"row\"\n showRowSelection={true}\n showRowNumbers={true}\n showPagination={false}\n enableInlineEditing={false}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => (api = next)}\n onRowSelectionChange={(_, sel) => (selectedRows = sel)}\n />\n </div>\n\n <footer class=\"text-xs text-slate-500 dark:text-slate-400 shrink-0\">\n {rows.length} rows total · the toolbar appears only while rows are selected.\n </footer>\n</section>\n\n<style>\n .action-btn {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n height: 28px;\n padding: 0 10px;\n border-radius: 6px;\n border: 1px solid var(--sg-border);\n background: var(--sg-bg);\n color: var(--sg-fg);\n font-size: 12px;\n font-weight: 500;\n cursor: pointer;\n transition: background 120ms ease, border-color 120ms ease;\n }\n .action-btn:hover {\n background: var(--sg-row-hover-bg);\n border-color: color-mix(in srgb, var(--site-accent, #6366f1) 35%, var(--sg-border));\n }\n .action-btn-danger {\n color: #ef4444;\n border-color: color-mix(in srgb, #ef4444 28%, var(--sg-border));\n }\n .action-btn-danger:hover {\n background: color-mix(in srgb, #ef4444 8%, transparent);\n border-color: color-mix(in srgb, #ef4444 55%, var(--sg-border));\n }\n</style>\n"
164
+ },
165
+ {
166
+ "id": "24-validation",
167
+ "path": "examples/src/demos/24-validation.svelte",
168
+ "title": "Validation",
169
+ "blurb": "24. Validation while editing Per-column validators that run on every commit. Invalid edits are rolled back via api.setCellValue(rowIndex, columnId, oldValue), the",
170
+ "source": "<script lang=\"ts\">\n /**\n * 24. Validation while editing\n * ----------------------------\n * Per-column validators that run on every commit. Invalid edits are\n * rolled back via api.setCellValue(rowIndex, columnId, oldValue), the\n * cell flashes red briefly, and the rejection is logged in the\n * \"Recent rejections\" panel.\n *\n * SvGrid v1.0 does not yet have a per-column `validate()` hook (it's on\n * the missing-features list). The pattern below — validate in\n * onCellValueChange + roll back via setCellValue — is the production\n * workaround. The same shape will adapt cleanly when the built-in hook\n * lands.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n type SvGridApi,\n } from 'sv-grid-community'\n import { makePeople, type Person } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n })\n\n let rows = $state<Person[]>(makePeople(60))\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n\n type Rejection = { ts: number; row: number; field: string; tried: string; reason: string }\n let rejections = $state<Rejection[]>([])\n let flashCell = $state<string | null>(null) // `${rowIndex}:${columnId}` for transient highlight\n\n type Validator = (value: unknown, row: Person) => string | null\n\n const today = new Date().toISOString().slice(0, 10)\n\n // Per-column rules. Return null when valid, a short message when not.\n const validators: Partial<Record<keyof Person, Validator>> = {\n firstName: (v) => (typeof v === 'string' && v.trim().length >= 1\n ? null : 'First name is required'),\n lastName: (v) => (typeof v === 'string' && v.trim().length >= 1\n ? null : 'Last name is required'),\n email: (v) => {\n if (typeof v !== 'string') return 'Email must be text'\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(v) ? null : 'Not a valid email address'\n },\n age: (v) => {\n const n = typeof v === 'number' ? v : Number(v)\n if (!Number.isFinite(n) || !Number.isInteger(n)) return 'Age must be a whole number'\n if (n < 18) return 'Minimum age is 18'\n if (n > 99) return 'Maximum age is 99'\n return null\n },\n salary: (v) => {\n const n = typeof v === 'number' ? v : Number(v)\n if (!Number.isFinite(n)) return 'Salary must be a number'\n if (n < 0) return 'Salary cannot be negative'\n if (n > 1_000_000) return 'Salary cannot exceed $1,000,000'\n return null\n },\n joinedAt: (v) => {\n if (typeof v !== 'string' || !/^\\d{4}-\\d{2}-\\d{2}$/.test(v)) return 'Use YYYY-MM-DD'\n if (v > today) return 'Cannot be in the future'\n return null\n },\n }\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name *', editorType: 'text', width: 130 },\n { field: 'lastName', header: 'Last name *', editorType: 'text', width: 130 },\n { field: 'email', header: 'Email (must be valid)', editorType: 'text', width: 230 },\n { field: 'age', header: 'Age (18–99)', editorType: 'number', width: 110 },\n { field: 'salary', header: 'Salary ($0–$1M)', editorType: 'number', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n { field: 'joinedAt', header: 'Joined (past dates only)', editorType: 'date', width: 200,\n format: { type: 'date', pattern: 'y-m-d' } },\n { field: 'department', header: 'Department (no validation)', editorType: 'text', width: 180 },\n ]\n\n function onCellValueChange(e: {\n rowIndex: number\n columnId: string\n oldValue: unknown\n newValue: unknown\n row: Person\n }) {\n const rule = validators[e.columnId as keyof Person]\n if (!rule) return\n const error = rule(e.newValue, e.row)\n if (!error) return\n\n // Reject: roll the value back, log the rejection, flash the cell.\n rejections = [\n { ts: Date.now(), row: e.rowIndex + 1, field: e.columnId, tried: String(e.newValue), reason: error },\n ...rejections,\n ].slice(0, 8)\n\n const cellKey = `${e.rowIndex}:${e.columnId}`\n flashCell = cellKey\n setTimeout(() => { if (flashCell === cellKey) flashCell = null }, 900)\n\n // Defer to next tick so the grid finishes its commit cycle before we\n // overwrite the value — avoids any in-flight render race.\n queueMicrotask(() => api?.setCellValue(e.rowIndex, e.columnId, e.oldValue))\n }\n\n function clearRejections() {\n rejections = []\n }\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div class=\"text-sm text-slate-600 dark:text-slate-300 shrink-0\">\n Double-click any cell (or press <kbd>F2</kbd>) to edit. Try an invalid value —\n blank name, malformed email, age 200, future date — and watch the commit\n get rejected and rolled back. Valid edits stick.\n </div>\n\n <!-- Recent rejections panel — tiny audit log so reviewers see validation\n running across many edits. -->\n <div\n class=\"shrink-0 rounded-lg border\"\n style=\"border-color: var(--sg-border); background: var(--sg-header-bg);\"\n >\n <div class=\"flex items-center justify-between px-3 py-2 border-b\"\n style=\"border-color: var(--sg-border);\">\n <p class=\"text-xs font-semibold uppercase tracking-wider\" style=\"color: var(--sg-muted);\">\n Recent rejections {#if rejections.length}({rejections.length}){/if}\n </p>\n {#if rejections.length}\n <button type=\"button\" class=\"rej-clear\" onclick={clearRejections}>Clear</button>\n {/if}\n </div>\n <div class=\"max-h-28 overflow-y-auto px-3 py-2\">\n {#if rejections.length === 0}\n <p class=\"text-xs italic\" style=\"color: var(--sg-muted);\">\n No rejected commits yet — every edit so far has passed validation.\n </p>\n {:else}\n <ul class=\"space-y-1\">\n {#each rejections as r (r.ts)}\n <li class=\"text-xs flex flex-wrap gap-x-2\" style=\"color: var(--sg-fg);\">\n <span style=\"color: #f87171; font-weight: 600;\">row {r.row}</span>\n <span style=\"color: var(--sg-muted);\">·</span>\n <code style=\"color: var(--site-accent-2, #22d3ee);\">{r.field}</code>\n <span style=\"color: var(--sg-muted);\">=</span>\n <code style=\"color: var(--sg-fg);\">\"{r.tried}\"</code>\n <span style=\"color: var(--sg-muted);\">→</span>\n <span>{r.reason}</span>\n </li>\n {/each}\n </ul>\n {/if}\n </div>\n </div>\n\n <div class=\"flex-1 min-h-0 relative\">\n {#if flashCell}\n <!-- Brief visual nudge that the most recent edit was rejected. -->\n <div\n class=\"absolute top-2 right-3 z-10 text-xs px-2 py-1 rounded\"\n style=\"background: #7f1d1d; color: #fecaca;\"\n >\n Rejected: value rolled back\n </div>\n {/if}\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showRowNumbers={true}\n showPagination={true}\n enableInlineEditing={true}\n enableCellSelection={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => (api = next)}\n onCellValueChange={onCellValueChange}\n />\n </div>\n</section>\n\n<style>\n .rej-clear {\n font-size: 10px;\n text-transform: uppercase;\n letter-spacing: 0.04em;\n color: var(--sg-muted);\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 2px 6px;\n border-radius: 4px;\n }\n .rej-clear:hover {\n color: var(--sg-fg);\n background: var(--sg-row-hover-bg);\n }\n</style>\n"
171
+ },
172
+ {
173
+ "id": "25-column-pinning",
174
+ "path": "examples/src/demos/25-column-pinning.svelte",
175
+ "title": "Column Pinning",
176
+ "blurb": "25. Column pinning + freezing Wide grid with 13 columns so horizontal scrolling kicks in. Pin \"Company\" left and \"Price\" right so they stay visible while the",
177
+ "source": "<script lang=\"ts\">\n /**\n * 25. Column pinning + freezing\n * -----------------------------\n * Wide grid with 13 columns so horizontal scrolling kicks in. Pin\n * \"Company\" left and \"Price\" right so they stay visible while the\n * middle columns scroll. Pinned cells use sticky positioning — the\n * grid handles offsets automatically.\n *\n * In v1.0 pinning is driven by the column-menu UX (click the ⋮ on any\n * header). A programmatic api.setColumnPinning(id, side) is on the\n * v1.x roadmap.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n } from 'sv-grid-community'\n import { makeOrders, type Order } from '../shared/seed'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n })\n\n let rows = $state<Order[]>(makeOrders(80))\n\n // Intentionally wide column set so the grid overflows horizontally;\n // pinning becomes meaningful only when there's something to scroll past.\n const columns: ColumnDef<typeof features, Order>[] = [\n { field: 'company', header: 'Company', width: 170 },\n { field: 'product', header: 'Product', width: 220 },\n { field: 'orderId', header: 'Order ID', width: 140 },\n { field: 'country', header: 'Country', width: 110 },\n { field: 'sellDate', header: 'Sell date', width: 130,\n format: { type: 'date', pattern: 'y-m-d' } },\n { field: 'quantity', header: 'Quantity', width: 110,\n format: { type: 'number', options: { maximumFractionDigits: 0 } } },\n { field: 'inStock', header: 'In stock', width: 110 },\n { field: 'price', header: 'Unit price', width: 130,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <div\n class=\"shrink-0 rounded-lg border px-4 py-3\"\n style=\"border-color: var(--sg-border); background: var(--sg-header-bg);\"\n >\n <p class=\"text-sm font-semibold\" style=\"color: var(--sg-fg);\">\n How to pin a column\n </p>\n <ol class=\"mt-1 text-xs list-decimal pl-5 space-y-0.5\" style=\"color: var(--sg-fg);\">\n <li>\n Hover any header — click the <code style=\"color: var(--site-accent-2, #22d3ee);\">⋮</code>\n button that appears (or right-click the header).\n </li>\n <li>\n Pick <strong>Pin column left</strong> or <strong>Pin column right</strong>.\n </li>\n <li>\n Scroll horizontally. The pinned column stays put while the others slide.\n </li>\n </ol>\n <p class=\"mt-2 text-xs\" style=\"color: var(--sg-muted);\">\n Suggested layout to evaluate: pin <strong>Company</strong> left and\n <strong>Unit price</strong> right, then scroll. The middle columns disappear\n under the sticky edges while Company and Price stay anchored.\n </p>\n </div>\n\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showRowNumbers={true}\n showPagination={true}\n enableCellSelection={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={false}\n />\n </div>\n\n <footer class=\"text-xs text-slate-500 dark:text-slate-400 shrink-0\">\n {rows.length} rows · {columns.length} columns ·\n <code>fitColumns={`{false}`}</code> so the grid keeps its declared widths\n and overflows horizontally — pinning only matters when the grid scrolls.\n </footer>\n</section>\n"
178
+ }
179
+ ];
180
+ export const docs = [
181
+ {
182
+ "slug": "getting-started",
183
+ "path": "docs/getting-started.md",
184
+ "title": "Getting Started with SvGrid",
185
+ "markdown": "# Getting Started with SvGrid\n\nSvGrid is a modern, production-ready data grid for Svelte 5 — a headless\ncore engine paired with a Svelte render component\n(`<SvGrid>`). It scales from a 10-row read-only table to a virtualized\n100,000-row, 100-column editing surface with grouping, multi-column\nfiltering, server-side data, and full keyboard and screen-reader\nsupport.\n\nThis page walks you from `pnpm add` to a feature-complete grid. It is\nthe canonical entry point — every other page in the documentation\nassumes you've finished this one. Estimated reading time: 15 minutes.\n\n> **New here?** Two short companion reads:\n>\n> - [Why headless?](./why-headless.md) - the architecture decision\n> behind the `createSvGrid` core vs. the `<SvGrid>` renderer.\n> - [Tailwind integration](./help/tailwind.md) - how `--sg-*` custom\n> properties + Tailwind v4 + dark mode fit together.\n\n> `sv-grid-community` is published under the **MIT License** — permissive\n> for commercial use, redistribution, and modification. The paid companion\n> `sv-grid-pro` (data export + print) ships under a separate commercial\n> license. See [LICENSE](../LICENSE) and\n> [packages/sv-grid-pro/LICENSE](../packages/sv-grid-pro/LICENSE).\n\n---\n\n## Contents\n\n1. [Your first grid in 60 seconds](#1-your-first-grid-in-60-seconds)\n2. [Install the package](#2-install-the-package)\n3. [Provide row data](#3-provide-row-data)\n4. [Define column definitions](#4-define-column-definitions)\n5. [Register features (row models)](#5-register-features-row-models)\n6. [Styling: theme, density, dark mode](#6-styling-theme-density-dark-mode)\n7. [Sizing the grid](#7-sizing-the-grid)\n8. [Custom cells with FlexRender](#8-custom-cells-with-flexrender)\n9. [Sorting, filtering, pagination](#9-sorting-filtering-pagination)\n10. [Selection, editing, keyboard](#10-selection-editing-keyboard)\n11. [Server-side data](#11-server-side-data)\n12. [Virtualization for large datasets](#12-virtualization-for-large-datasets)\n13. [Accessibility](#13-accessibility)\n14. [TypeScript notes](#14-typescript-notes)\n15. [What's next](#15-whats-next)\n\n---\n\n## 1. Your first grid in 60 seconds\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, type ColumnDef } from 'sv-grid-community'\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: ColumnDef<{}, 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. The rest of this page is about turning\nit into something you'd ship.\n\n---\n\n## 2. Install the package\n\nSvGrid is a single npm package. There is no peer dependency on a CSS\nframework — bring your own, or use the bundled stylesheet.\n\n```bash\n# pnpm (recommended)\npnpm add sv-grid-community\n\n# npm\nnpm install sv-grid-community\n\n# yarn\nyarn add sv-grid-community\n```\n\n**Requirements.**\n\n- Svelte **5.x** (uses runes — `$state`, `$derived`, `$effect`).\n- TypeScript **5.4+** (optional but recommended).\n- Node **18+** for tooling.\n\nOnce installed, import the component, the row-model factories you need,\nand (optionally) the default stylesheet:\n\n```ts\nimport {\n SvGrid,\n createCoreRowModel,\n createSortedRowModel,\n createFilteredRowModel,\n createPaginatedRowModel,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n type ColumnDef,\n} from 'sv-grid-community'\n\nimport 'sv-grid-community/themes/default.css' // optional\n```\n\nThe bundle is tree-shakeable. Features you don't import don't ship.\n\n---\n\n## 3. Provide row data\n\nSvGrid is data-agnostic. The `data` prop is any\n`ReadonlyArray<TRow>` — a Svelte 5 `$state` array, a derived store, an\nSWR/React-query-style cache, the result of a `+page.ts` load function,\nor a plain literal.\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid } from 'sv-grid-community'\n\n type Person = { id: string; firstName: string; age: number }\n\n // Reactive: pushing into `rows` updates the grid automatically.\n let rows = $state<Person[]>([\n { id: '1', firstName: 'Ada', age: 36 },\n { id: '2', firstName: 'Linus', age: 54 },\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={columns} />\n```\n\n**Identity.** If your rows have a stable id, pass `getRowId` so SvGrid\ncan preserve selection, expansion, and edit state across data updates\ninstead of falling back to row index:\n\n```ts\n<SvGrid\n data={rows}\n columns={columns}\n getRowId={(row) => row.id}\n/>\n```\n\n**Immutability.** SvGrid never mutates your data. When you edit a cell\nthe grid emits an event; you decide whether to mutate in place or copy.\nSee [§10 — Editing](#10-selection-editing-keyboard).\n\n---\n\n## 4. Define column definitions\n\nA column definition tells SvGrid how to read a value out of a row, how\nto render it, and which features apply to it.\n\n```ts\nimport type { ColumnDef } from 'sv-grid-community'\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: ColumnDef<{}, Person>[] = [\n // Simple accessor by key\n { field: 'firstName', header: 'First name' },\n\n // Computed accessor\n {\n id: 'fullName',\n header: 'Full name',\n accessorFn: (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| `accessorFn` | Computes the value from the row. |\n| `id` | Stable column id (required if you use `accessorFn`). |\n| `header` | String or render snippet for the header. |\n| `cell` | Render snippet/component for the body cell. |\n| `format` | Locale-aware formatter (`number`, `currency`, `percent`, `date`). |\n| `editorType` | Inline editor: `text` \\| `number` \\| `checkbox` \\| `date`. |\n| `enableSorting` | Defaults to `true` when the sorting feature is registered. |\n| `enableColumnFilter` | Defaults to `true` when the filtering feature is registered. |\n| `enableGrouping` | Lets users drag this column into the group zone. |\n| `meta` | Free-form per-column metadata that flows into renderers. |\n\nSee [`packages/sv-grid-community/src/core.ts`](../packages/sv-grid-community/src/core.ts)\nfor the full type.\n\n---\n\n## 5. Register features (row models)\n\nThe grid engine is feature-gated. Out of the box you get the **core row\nmodel** (the rows in their original order). To enable sorting,\nfiltering, grouping, expansion, pagination, or selection you opt in\nwith `tableFeatures(...)` and the matching `create*RowModel` factory.\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n rowSelectionFeature,\n createCoreRowModel,\n createSortedRowModel,\n createFilteredRowModel,\n createPaginatedRowModel,\n type ColumnDef,\n } from 'sv-grid-community'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n rowSelectionFeature,\n })\n\n const rowModels = {\n coreRowModel: createCoreRowModel(),\n sortedRowModel: createSortedRowModel(),\n filteredRowModel: createFilteredRowModel(),\n paginatedRowModel: createPaginatedRowModel(),\n }\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n rowModels={rowModels}\n initialState={{ pagination: { pageIndex: 0, pageSize: 25 } }}\n/>\n```\n\n**Rule of thumb.** Only register the row models you use. Each one is\n~1–2 KB gzipped and adds a small per-update cost.\n\n| Feature | Factory | What it does |\n| --- | --- | --- |\n| `rowSortingFeature` | `createSortedRowModel` | Click headers to sort; shift-click for multi-sort. |\n| `columnFilteringFeature` | `createFilteredRowModel` | Per-column filters with built-in `filterFns`. |\n| `rowPaginationFeature` | `createPaginatedRowModel` | Page slicing + footer state. |\n| `rowSelectionFeature` | — | Row checkboxes, range selection, headless API. |\n| `columnGroupingFeature` | `createGroupedRowModel` | Group-by-column + aggregators. |\n| `rowExpandingFeature` | `createExpandedRowModel` | Tree / master-detail expansion. |\n\n---\n\n## 6. Styling: theme, density, dark mode\n\nSvGrid ships with two CSS files you can opt into and a CSS custom-\nproperty surface for everything else.\n\n```ts\nimport 'sv-grid-community/themes/default.css' // light + dark via prefers-color-scheme\nimport 'sv-grid-community/themes/high-contrast.css' // WCAG AAA palette\n```\n\n**Customising tokens.** Every visual property is exposed as a custom\nproperty prefixed with `--sg-`. Override at any level — `:root`, a\nwrapper, or directly on `<SvGrid>`.\n\n```css\n:root {\n --sg-row-height: 36px;\n --sg-header-bg: #f6f7f9;\n --sg-header-fg: #1f2933;\n --sg-row-hover-bg: #eef2ff;\n --sg-selection-bg: #dbeafe;\n --sg-border: #e5e7eb;\n --sg-focus-ring: 0 0 0 2px #2563eb;\n --sg-font: 'Inter', system-ui, sans-serif;\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --sg-header-bg: #0f172a;\n --sg-header-fg: #f1f5f9;\n --sg-row-hover-bg: #1e293b;\n --sg-border: #334155;\n }\n}\n```\n\n**Density.** The default theme reads `--sg-row-height`; flip it to\n`28px` for compact mode and `48px` for comfortable. Density changes are\napplied without remounting the virtualizer.\n\n**Reduced motion.** Sort animations and expand transitions respect\n`prefers-reduced-motion: reduce` automatically.\n\n---\n\n## 7. Sizing the grid\n\n`<SvGrid>` fills its parent. Give it a height and it scrolls — without\none, it expands to its content and never virtualises.\n\n```svelte\n<!-- Fixed: 600px tall, full width. The typical choice. -->\n<div style=\"height: 600px;\">\n <SvGrid data={rows} columns={columns} />\n</div>\n\n<!-- Flexible: fills the viewport minus header/footer. -->\n<div class=\"grid-shell\">\n <SvGrid data={rows} columns={columns} />\n</div>\n\n<style>\n .grid-shell {\n height: calc(100dvh - 4rem);\n }\n</style>\n```\n\n**Auto-height (small datasets only).** For grids with fewer than ~200\nrows you can let the grid grow to its content:\n\n```svelte\n<SvGrid data={rows} columns={columns} domLayout=\"autoHeight\" />\n```\n\nAuto-height disables row virtualization. Don't use it for large data.\n\n---\n\n## 8. Custom cells with FlexRender\n\nFor anything beyond a stringified value, render with `FlexRender`,\n`renderComponent`, or `renderSnippet`.\n\n### As a Svelte snippet\n\n```svelte\n<script lang=\"ts\">\n import { renderSnippet, type ColumnDef } from 'sv-grid-community'\n</script>\n\n{#snippet StatusCell({ value }: { value: string })}\n <span class=\"pill pill-{value}\">{value}</span>\n{/snippet}\n\n<script lang=\"ts\">\n const columns: ColumnDef<{}, Person>[] = [\n {\n field: 'status',\n header: 'Status',\n cell: renderSnippet(StatusCell, (ctx) => ({ value: ctx.getValue() as string })),\n },\n ]\n</script>\n```\n\n### As a Svelte component\n\n```ts\nimport StatusBadge from './StatusBadge.svelte'\nimport { renderComponent } from 'sv-grid-community'\n\nconst columns = [\n {\n field: 'status',\n header: 'Status',\n cell: renderComponent(StatusBadge, (ctx) => ({ status: ctx.getValue() })),\n },\n]\n```\n\n`renderComponent` and `renderSnippet` both receive a\n`CellContext` so you can read sibling values, mutate state, or call\nback into the grid via `ctx.table`.\n\n---\n\n## 9. Sorting, filtering, pagination\n\nOnce their features are registered (see §5) the UI affordances appear\nautomatically. The state is controllable.\n\n### Uncontrolled (start state via `initialState`)\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n rowModels={rowModels}\n initialState={{\n sorting: [{ id: 'age', desc: true }],\n columnFilters: [{ id: 'status', value: 'active' }],\n pagination: { pageIndex: 0, pageSize: 50 },\n }}\n/>\n```\n\n### Controlled (drive state from the outside)\n\nControlled state is required for URL persistence, server-side mode,\nand any cross-component coordination.\n\n```svelte\n<script lang=\"ts\">\n import type { SortingState } from 'sv-grid-community'\n\n let sorting = $state<SortingState>([])\n let columnFilters = $state<Array<{ id: string; value: unknown }>>([])\n let pagination = $state({ pageIndex: 0, pageSize: 25 })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n rowModels={rowModels}\n state={{ sorting, columnFilters, pagination }}\n onSortingChange={(next) => (sorting = next)}\n onColumnFiltersChange={(next) => (columnFilters = next)}\n onPaginationChange={(next) => (pagination = next)}\n/>\n```\n\n**Built-in filter functions** live in `filterFns`:\n`includesString`, `equalsString`, `arrIncludes`, `inNumberRange`,\n`isAfter`, `isBefore`, `isEmpty`, `isNotEmpty`. Specify per column:\n\n```ts\n{ field: 'age', header: 'Age', filterFn: 'inNumberRange' }\n```\n\nFor Excel-style filter chips and per-column dropdown menus, see\n[`applyExcelFilter`](../packages/sv-grid-community/src/filtering/excel-filters.ts).\n\n---\n\n## 10. Selection, editing, keyboard\n\n### Row selection\n\n```svelte\n<script lang=\"ts\">\n let rowSelection = $state<Record<string, boolean>>({})\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n rowModels={rowModels}\n getRowId={(row) => row.id}\n state={{ rowSelection }}\n onRowSelectionChange={(next) => (rowSelection = next)}\n enableMultiRowSelection\n/>\n\n{#if Object.keys(rowSelection).length}\n <p>{Object.keys(rowSelection).length} selected</p>\n{/if}\n```\n\n### Cell editing\n\nSet `editorType` on each editable column. The grid handles entry,\ncommit, and cancel; you handle persistence.\n\n```svelte\n<script lang=\"ts\">\n function handleCellEdit(event: {\n rowId: string\n columnId: string\n value: unknown\n }) {\n const row = rows.find((r) => r.id === event.rowId)\n if (!row) return\n ;(row as Record<string, unknown>)[event.columnId] = event.value\n }\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n getRowId={(row) => row.id}\n onCellValueChange={handleCellEdit}\n/>\n```\n\n### Keyboard\n\nThe grid follows the WAI-ARIA grid pattern:\n\n| Keys | Action |\n| --- | --- |\n| `←` `↑` `→` `↓` | Move active cell |\n| `Home` / `End` | First / last column of the row |\n| `Ctrl+Home` / `Ctrl+End` | First / last cell of the grid |\n| `PageUp` / `PageDown` | Move one viewport |\n| `Shift + <move>` | Extend cell-range selection |\n| `Space` | Toggle row selection (when selection enabled) |\n| `Enter` / `F2` | Begin editing the active cell |\n| `Esc` | Cancel edit / clear selection |\n| `Ctrl/Cmd + C` | Copy selection as TSV |\n| `Ctrl/Cmd + V` | Paste TSV into selection |\n\nIf you implement your own header or toolbar, route keys through\n`getKeyboardIntent` and `getNextActiveCell` so behaviour stays\nconsistent.\n\n---\n\n## 11. Server-side data\n\nFor datasets that don't fit in memory, drive the grid from the server.\nThe pattern is: control the state, translate it into a query, fetch,\nhand the page back to the grid.\n\n```svelte\n<script lang=\"ts\">\n import type { SortingState } from 'sv-grid-community'\n\n let sorting = $state<SortingState>([])\n let columnFilters = $state<Array<{ id: string; value: unknown }>>([])\n let pagination = $state({ pageIndex: 0, pageSize: 50 })\n\n let rows = $state<Person[]>([])\n let total = $state(0)\n let loading = $state(false)\n\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(sorting),\n filters: JSON.stringify(columnFilters),\n page: String(pagination.pageIndex),\n pageSize: String(pagination.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(() => {\n // Re-run whenever any of these change.\n sorting; columnFilters; pagination\n load()\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n rowModels={{ coreRowModel: createCoreRowModel() }}\n manualSorting\n manualFiltering\n manualPagination\n rowCount={total}\n state={{ sorting, columnFilters, pagination }}\n onSortingChange={(next) => (sorting = next)}\n onColumnFiltersChange={(next) => (columnFilters = next)}\n onPaginationChange={(next) => (pagination = next)}\n/>\n\n{#if loading}<div class=\"overlay\">Loading…</div>{/if}\n```\n\nThe `manual*` props tell the grid not to re-derive that dimension\nlocally — the data you pass in is already the answer.\n\n---\n\n## 12. Virtualization for large datasets\n\nFor more than a few thousand rows, enable row virtualization. For very\nwide grids (50+ columns) also enable column virtualization. Both are\nopt-in so small grids don't pay the cost.\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid } from 'sv-grid-community'\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n virtualizeRows\n virtualizeColumns\n estimatedRowHeight={36}\n overscan={6}\n/>\n```\n\nFor full control (e.g. variable row heights, programmatic scroll),\nuse the headless virtualizer directly:\n\n```ts\nimport { createSvelteVirtualizer } from 'sv-grid-community'\n\nconst virtualizer = createSvelteVirtualizer({\n count: () => rows.length,\n getScrollElement: () => scrollRef,\n estimateSize: (index) => (rows[index].kind === 'header' ? 40 : 28),\n overscan: 6,\n})\n\n// Programmatic scroll:\nvirtualizer.scrollToIndex(75_432, { align: 'center' })\n```\n\nSee [`packages/sv-grid-community/src/virtualization/`](../packages/sv-grid-community/src/virtualization/)\nfor the full API.\n\n---\n\n## 13. Accessibility\n\nSvGrid implements the WAI-ARIA 1.2 grid pattern.\n\n- The root carries `role=\"grid\"`, an accessible name (set via\n `aria-label` or `aria-labelledby`), and `aria-rowcount` /\n `aria-colcount` reflecting the total — not just the visible window.\n- Rows carry `role=\"row\"` plus `aria-rowindex` accounting for the\n virtualized offset; cells carry `role=\"gridcell\"` and `aria-colindex`.\n- The active cell is always exactly one focusable element\n (roving `tabindex`); arrow keys move it.\n- Sort columns carry `aria-sort=\"ascending\" | \"descending\" | \"none\"`.\n- Sort and selection state changes are announced via an off-screen\n `aria-live` region the grid manages internally.\n\nIf you build your own header or toolbar, use the helpers in\n[`a11y.ts`](../packages/sv-grid-community/src/a11y.ts) so your markup\nstays consistent with the contract:\n\n```ts\nimport {\n getGridRootA11yProps,\n getGridRowA11yProps,\n getGridCellA11yProps,\n getGridHeaderA11yProps,\n} from 'sv-grid-community'\n```\n\nThere is a contract test suite at\n[`a11y.contract.test.ts`](../packages/sv-grid-community/src/a11y.contract.test.ts)\nthat exercises the public a11y guarantees — run it (`pnpm test`) when\nyou customize markup to be sure you haven't regressed the contract.\n\n---\n\n## 14. TypeScript notes\n\nMost APIs are generic over your row type. Define the row type once and\nflow it through:\n\n```ts\ntype Person = { id: string; firstName: string; age: number }\n\nconst columns: ColumnDef<{}, Person>[] = [\n { field: 'firstName', header: 'First name' }, // ✅ key checked\n // { field: 'first_name', header: '…' }, // ✗ TS error\n]\n```\n\nThe first type parameter is the **feature set**. When you register\nfeatures, derive it once and reuse:\n\n```ts\nconst features = tableFeatures({\n rowSortingFeature,\n rowSelectionFeature,\n columnFilteringFeature,\n})\n\ntype Features = typeof features\n\nconst columns: ColumnDef<Features, Person>[] = [/* … */]\n```\n\nThis lets feature-specific column properties (like `filterFn`)\nauto-complete and type-check.\n\n---\n\n## 15. What's next\n\n- **[Examples gallery](https://sv-grid.github.io/sv-grid/#/demos)** — 20 production-quality\n demos, from quick-start to 100k-row virtualization.\n- **[Column definitions](./help/columns/column-definitions.md)** — every\n property on `ColumnDef`.\n- **[Row sorting](./help/rows/row-sorting.md)** and the wider [rows topic index](./help/index.md#rows) —\n when to use which row model and the order they run in.\n- **[Filter API](./help/filtering/filter-api.md)** — sort, filter, paginate,\n group, and aggregate locally or against your backend.\n- **[Tailwind integration](./help/tailwind.md)** — full list of CSS custom\n properties (`--sg-*`) and recipes for building your own theme.\n- **[Compare SvGrid with other Svelte data grids](https://sv-grid.github.io/sv-grid/#/compare)** —\n side-by-side feature matrix and when to pick which.\n\n### Getting help\n\n- File issues at the [project repository](https://github.com/sv-grid/sv-grid/issues).\n- Browse the [Help index](./help/index.md) for topic-oriented guides.\n- Use the [sv-grid-mcp](https://sv-grid.github.io/sv-grid/#/mcp) server to give your AI assistant accurate answers.\n- Read the source — it is small, well-commented, and meant to be read\n before opening a bug report.\n\n### License\n\n`sv-grid-community` is published under the **MIT License**. Free for\ncommercial and personal use. The paid `sv-grid-pro` companion package\n(data export + print) is governed by a separate commercial license.\nSee [LICENSE](../LICENSE) and\n[packages/sv-grid-pro/LICENSE](../packages/sv-grid-pro/LICENSE).\n"
186
+ },
187
+ {
188
+ "slug": "help/cells/cell-components",
189
+ "path": "docs/help/cells/cell-components.md",
190
+ "title": "Cell components",
191
+ "markdown": "# Cell components\n\nFor any cell whose content is more than a string, use `cell:` with\n`renderSnippet` or `renderComponent`.\n\n## Snippet\n\n```svelte\n<script lang=\"ts\">\n import { renderSnippet, type ColumnDef } from 'sv-grid-community'\n\n const columns: ColumnDef<{}, Person>[] = [\n {\n field: 'status',\n header: 'Status',\n cell: (ctx) => renderSnippet(Pill, { value: String(ctx.getValue()) }),\n },\n ]\n</script>\n\n{#snippet Pill(p: { value: string })}\n <span class=\"pill pill-{p.value}\">{p.value}</span>\n{/snippet}\n```\n\nSnippets are the right choice when the renderer is local to the page and\nsmall.\n\n## Component\n\n```ts\nimport StatusBadge from './StatusBadge.svelte'\nimport { renderComponent } from 'sv-grid-community'\n\n{\n field: 'status',\n cell: (ctx) => renderComponent(StatusBadge, { status: ctx.getValue() }),\n}\n```\n\nComponents are the right choice when the renderer is reused across\nmultiple grids, has its own state, or needs lifecycle hooks.\n\n## CellContext\n\nThe argument the grid passes to your `cell` callback:\n\n```ts\ntype CellContext<TData> = {\n cell: Cell<TData>\n row: Row<TData>\n column: Column<TData>\n table: SvGrid<TData>\n getValue: () => unknown\n}\n```\n\n- `getValue()` — the accessed value (post-`field` / `accessorFn`).\n- `row.original` — the raw `TData` object.\n- `row.getAllCells()` — every cell in the row, for sibling reads.\n- `column.columnDef` — the original `ColumnDef`.\n- `table` — the headless grid instance, with state and actions.\n\n## Inline string\n\nIf your cell content is a plain string and you just want to format it, use\n`format` or `formatter` — not `cell`. See\n[Text formatting](./text-formatting.md).\n\n## Performance\n\nCell renderers run once per visible cell on each grid update. For large\nvirtualized grids, keep them cheap:\n\n- avoid `JSON.stringify`\n- avoid `new Date()` per cell — pre-compute formatters at module scope\n- avoid creating new objects inside the snippet template\n\n## Common patterns\n\n- **Avatar + name** — return a snippet that pulls first/last name from\n `ctx.row.original`. See demo 10.\n- **Status pill** — class-derived background. See demo 10.\n- **Inline progress bar** — `<div role=\"progressbar\" aria-valuenow>`. See demo 10.\n- **Hyperlink** — `<a href=\"/people/{ctx.row.original.id}\">{ctx.getValue()}</a>`.\n\n## See also\n\n- [Custom header components](../columns/custom-header-components.md)\n- [demos/10-custom-cells-and-themes.svelte](../../../examples/src/demos/10-custom-cells-and-themes.svelte)\n"
192
+ },
193
+ {
194
+ "slug": "help/cells/cell-data-types",
195
+ "path": "docs/help/cells/cell-data-types.md",
196
+ "title": "Cell data types",
197
+ "markdown": "# Cell data types\n\nThe `editorType` field on a column tags the column with a type. This drives\nthree different behaviours:\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: ColumnDef<{}, 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## 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 `accessorFn` 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/sv-grid-community/src/editors/cell-editors.ts).\n\n```ts\nimport { parseEditorValue } from 'sv-grid-community'\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## See also\n\n- [Provided cell editors](../editing/provided-editors.md)\n- [Filter conditions](../filtering/filter-conditions.md)\n"
198
+ },
199
+ {
200
+ "slug": "help/cells/cell-text-selection",
201
+ "path": "docs/help/cells/cell-text-selection.md",
202
+ "title": "Cell text selection",
203
+ "markdown": "# Cell text selection\n\nBy default the grid has **cell-range selection** (drag across cells, range\nhighlight, copy as TSV). Standard browser text-selection inside a cell is\n**not** the default — clicking-and-dragging across a cell creates a range\nselection, not a text selection.\n\n## Enable browser text selection\n\nIf your users need to copy a substring from a single cell (e.g. an email\naddress that's wider than the cell), turn off cell-range selection for\nthat part of the grid. The simplest scope is \"everywhere\":\n\n```svelte\n<SvGrid\n {data} {columns} features={{}}\n selectionMode=\"row\" <!-- disables cell-range, keeps row selection -->\n/>\n```\n\nOr surgically, per cell, render the value in a `<span>` whose `user-select`\noverrides the grid's:\n\n```ts\n{\n field: 'email',\n cell: (ctx) => renderSnippet(SelectableEmail, { value: String(ctx.getValue()) }),\n}\n```\n\n```svelte\n{#snippet SelectableEmail(p: { value: string })}\n <span style=\"user-select: text;\">{p.value}</span>\n{/snippet}\n```\n\n## Copy current value\n\n`Ctrl/Cmd+C` with a cell range selection copies all selected cells as TSV.\nWith a single cell, the value is copied as a TSV scalar (no tab, no\nnewline).\n\nTo copy *just the displayed text* of a single cell without entering range\nselection, switch to a `selectionMode=\"row\"` and use the row checkbox\ncolumn + `Ctrl/Cmd+C` to copy entire rows.\n\n## Gotchas\n\n- A grid in `selectionMode='cell'` swallows mouse selection inside cells —\n drag *only* creates a rectangle selection, never a browser text selection.\n- A grid in `selectionMode='both'` (default) does too — the cell selection\n layer takes precedence.\n\n## See also\n\n- [Selection demo](../../../examples/src/demos/04-selection-copy-paste.svelte)\n- [Custom cells](./cell-components.md)\n"
204
+ },
205
+ {
206
+ "slug": "help/cells/expressions",
207
+ "path": "docs/help/cells/expressions.md",
208
+ "title": "Expressions",
209
+ "markdown": "# Expressions\n\nSvGrid does not ship a formula / expression language for cells. Computed\nvalues are JavaScript — either via `accessorFn` or inside a `cell`\ncallback.\n\n## Per-cell computation\n\n```ts\n{\n id: 'totalCost',\n header: 'Total',\n accessorFn: (row) => row.unitPrice * row.quantity,\n format: { type: 'currency', currency: 'USD' },\n}\n```\n\n`accessorFn` runs every time the row's value is needed (display, sort,\nfilter, copy). The result is treated as a plain value of the resulting\ntype, so `format` / `formatter` / `editorType` all apply.\n\n## Cross-row aggregation\n\nFor computed columns that depend on **other rows** (running total, rank,\ndelta-from-mean), do the computation **before** you pass data into the\ngrid — derive a new array with the aggregate fields baked in:\n\n```svelte\n<script lang=\"ts\">\n const sourceRows = await fetchOrders()\n const total = sourceRows.reduce((s, r) => s + r.amount, 0)\n const rows = sourceRows.map((r) => ({\n ...r,\n shareOfTotal: r.amount / total,\n }))\n</script>\n\n<SvGrid data={rows} {columns} features={features} />\n```\n\nThe grid's row pipeline runs **per row** — it does not give you a hook for\n\"emit a derived column that needs the whole array\".\n\n## Formula language\n\nA spreadsheet-style formula language (cells like `=A1+B2`) is **not** in the\nSvGrid community build. There is no formula parser or formula editor. If you\nneed spreadsheet-style cells, that's a separate library — wire its output\ninto a column's `accessorFn`.\n\n## See also\n\n- [Column definitions](../columns/column-definitions.md)\n- [Server-side guide](../../getting-started.md#11-server-side-data) — for aggregates the server is better at than the client.\n"
210
+ },
211
+ {
212
+ "slug": "help/cells/getting-values",
213
+ "path": "docs/help/cells/getting-values.md",
214
+ "title": "Getting values",
215
+ "markdown": "# Getting values\n\nYou get cell values in three ways depending on context.\n\n## Inside a column definition\n\n`field` does the obvious thing — `row[key]`:\n\n```ts\n{ field: 'firstName', header: 'First' }\n```\n\nUse `accessorFn` for anything computed:\n\n```ts\n{\n id: 'fullName',\n header: 'Full name',\n accessorFn: (row) => `${row.firstName} ${row.lastName}`,\n}\n```\n\n## Inside a cell renderer\n\nA `cell` callback receives a `CellContext`:\n\n```ts\n{\n field: 'salary',\n header: 'Salary',\n cell: (ctx) => {\n const value = ctx.getValue() // unknown\n const row = ctx.row.original // your TData\n const all = ctx.row.getAllCells() // array of Cell\n return /* renderSnippet / string / etc. */\n },\n}\n```\n\n`ctx.row.original` is the **raw row object** you passed in — handy when\nyou want sibling values without going through accessors.\n\n## From outside the grid\n\nAfter `onApiReady`:\n\n```ts\nconst v = api.getCellValue(rowIndex, columnId)\napi.setCellValue(rowIndex, columnId, newValue)\n```\n\n`rowIndex` is the index in the **source data array**, not the post-pipeline\ndisplayed index.\n\n## Reading by row id\n\nThere is no `api.getCellValueByRowId(rowId, columnId)` helper today. If you\nneed that, walk `api.getData()`:\n\n```ts\nfunction valueByRowId(api: SvGridApi<{}, Person>, rowId: string, col: string) {\n const data = api.getData()\n const idx = data.findIndex((r) => r.id === rowId)\n return idx === -1 ? undefined : api.getCellValue(idx, col)\n}\n```\n\n## See also\n\n- [Cell components](./cell-components.md)\n- [Accessing rows](../rows/accessing-rows.md)\n"
216
+ },
217
+ {
218
+ "slug": "help/cells/highlighting-changes",
219
+ "path": "docs/help/cells/highlighting-changes.md",
220
+ "title": "Highlighting changes",
221
+ "markdown": "# Highlighting changes\n\nThere is no built-in \"flash on change\" highlight. You build it with a\ndiff against a frozen snapshot of the data.\n\n## Dirty cells while editing\n\nDemo 5 ([demos/05-inline-editing.svelte](../../../examples/src/demos/05-inline-editing.svelte))\ndoes this:\n\n```svelte\n<script lang=\"ts\">\n // svelte-ignore state_referenced_locally\n let initial = rows.map((r) => ({ ...r }))\n let dirty = $state<Record<string, true>>({})\n\n $effect(() => {\n if (!api) return\n const snap = api.getData()\n const next: Record<string, true> = {}\n for (let i = 0; i < snap.length; i++) {\n const a = snap[i]!\n const b = initial[i]\n if (!b) continue\n for (const key of Object.keys(a)) {\n if ((a as any)[key] !== (b as any)[key]) {\n next[`${a.id}.${key}`] = true\n }\n }\n }\n dirty = next\n })\n</script>\n```\n\nTo make the dirty marker visible in the grid, render an indicator inside a\ncustom `cell`:\n\n```ts\n{\n field: 'salary',\n cell: (ctx) => renderSnippet(MaybeDirty, {\n value: ctx.getValue(),\n isDirty: dirty[`${ctx.row.original.id}.salary`] === true,\n }),\n}\n```\n\n```svelte\n{#snippet MaybeDirty(p: { value: unknown; isDirty: boolean })}\n <span class=\"inline-flex items-center gap-1\">\n {p.value}\n {#if p.isDirty}<span class=\"h-1.5 w-1.5 rounded-full bg-amber-500\"></span>{/if}\n </span>\n{/snippet}\n```\n\n## Flash on value change (live data)\n\nFor live-update grids (stock tickers, queue dashboards):\n\n```svelte\n<script lang=\"ts\">\n let lastValues = new Map<string, unknown>()\n let flashing = $state<Record<string, true>>({})\n\n function onCellSeen(key: string, value: unknown) {\n if (lastValues.has(key) && lastValues.get(key) !== value) {\n flashing[key] = true\n setTimeout(() => { delete flashing[key] }, 500)\n }\n lastValues.set(key, value)\n }\n</script>\n```\n\nDrive the flash from your cell renderer the same way. Use `prefers-\nreduced-motion` to disable the animation for users who opt out.\n\n## See also\n\n- [Cell components](./cell-components.md)\n- [demos/05-inline-editing.svelte](../../../examples/src/demos/05-inline-editing.svelte)\n"
222
+ },
223
+ {
224
+ "slug": "help/cells/styling-cells",
225
+ "path": "docs/help/cells/styling-cells.md",
226
+ "title": "Styling cells",
227
+ "markdown": "# Styling cells\n\nCells render as `<td>` and pick up the same CSS variable tokens as the rest\nof the grid.\n\n## Default look\n\n```css\ntable[role='grid'] td {\n border: 1px solid var(--sg-border);\n padding: 0.4rem 0.6rem;\n vertical-align: middle;\n}\n```\n\nOverride at `:root` or on the grid host.\n\n## Per-column styling\n\nThere is no `cellClass` callback on `ColumnDef` today. The cleanest path\nis a custom `cell` renderer that returns a `<span>` with the appropriate\nclass — the surrounding `<td>` will inherit `width` from the column but\nthe inner span controls the inside:\n\n```ts\n{\n field: 'salary',\n cell: (ctx) => {\n const v = Number(ctx.getValue())\n const cls = v > 100_000 ? 'text-emerald-600 font-semibold' : ''\n return renderSnippet(MoneyCell, { value: v, cls })\n },\n}\n```\n\nA `cellClass(ctx)` callback is on the\n[gap list](../missing-features.md).\n\n## Right-align numbers\n\nA common rule of thumb is \"numbers right, everything else left\". Target by\n`editorType`:\n\n```css\ntable[role='grid'] td[data-editor-type='number'] {\n text-align: right;\n font-variant-numeric: tabular-nums;\n}\n```\n\n(`data-editor-type` is set by the grid — verify by inspecting an element\nin your devtools; if the attribute is not present in your build, fall back\nto wrapping the value in a `<span class=\"tabular-nums\">`.)\n\n## Highlighting the active cell\n\nThe currently-active cell carries `aria-selected=\"true\"` and matches the\nfocus-ring custom property:\n\n```css\ntable[role='grid'] td[aria-selected='true'] {\n box-shadow: inset 0 0 0 2px var(--sg-accent);\n}\n```\n\n## Edit-mode cell\n\nWhile a cell is being edited, it carries `data-editing=\"true\"` and renders\nan `<input>` inside:\n\n```css\ntable[role='grid'] td[data-editing='true'] {\n padding: 0;\n}\ntable[role='grid'] td[data-editing='true'] input {\n width: 100%;\n height: 100%;\n padding: 0.4rem 0.6rem;\n border: 0;\n outline: none;\n background: var(--sg-bg);\n}\n```\n\n## See also\n\n- [Highlighting changes](./highlighting-changes.md)\n- [Cell components](./cell-components.md)\n"
228
+ },
229
+ {
230
+ "slug": "help/cells/text-formatting",
231
+ "path": "docs/help/cells/text-formatting.md",
232
+ "title": "Text formatting",
233
+ "markdown": "# Text formatting\n\nThe `format` field on a column produces locale-aware formatted strings\nwithout you writing a renderer.\n\n## Number\n\n```ts\n{ field: 'count', header: 'Count',\n format: { type: 'number', options: { maximumFractionDigits: 0 } } }\n```\n\n`options` is `Intl.NumberFormatOptions`. Combine with `locales` for\nnon-default locales:\n\n```ts\n{ field: 'count', header: 'Anzahl',\n format: { type: 'number', locales: 'de-DE', options: { maximumFractionDigits: 0 } } }\n```\n\n## Currency\n\n```ts\n{ field: 'salary', header: 'Salary',\n format: { type: 'currency', currency: 'USD' } }\n```\n\n`currency` is an ISO 4217 code; if omitted, USD is used.\n\n## Percent\n\n```ts\n// values are fractions (0.42 → 42%)\n{ field: 'utilization', header: 'Util',\n format: { type: 'percent' } }\n\n// values are 0–100 (42 → 42%)\n{ field: 'progress', header: 'Progress',\n format: { type: 'percent', valueIsPercentPoints: true } }\n```\n\n## Date / datetime\n\n```ts\n{ field: 'joinedAt', header: 'Joined',\n format: { type: 'date', pattern: 'y-m-d' } }\n\n{ field: 'updatedAt', header: 'Updated',\n format: { type: 'datetime', pattern: 'medium' } }\n```\n\nBuilt-in patterns:\n\n| pattern | shorthand for |\n| ------- | ------------- |\n| `'d'` | short numeric date |\n| `'D'` | long date |\n| `'y-m-d'` | year-month-day |\n| `'short'` \\| `'medium'` \\| `'long'` | `dateStyle` / `timeStyle` presets |\n\nCombine `pattern` with `options` to override individual fields.\n\n## Custom formatter\n\nFor anything `format` cannot express, use `formatter`:\n\n```ts\n{\n field: 'temperature',\n header: 'Temp',\n formatter: ({ value }) => `${Number(value).toFixed(1)}°C`,\n}\n```\n\n`formatter` runs **after** the accessor and **before** the cell renderer.\nIts return value is what gets displayed and copied to the clipboard.\n\n## Order of precedence\n\nWhen a column has both, the resolution order is:\n\n1. `field` / `accessorFn` produces the value\n2. If `cell` is set, it renders — `format` / `formatter` are ignored\n3. Otherwise `formatter` runs if set\n4. Otherwise `format` runs if set\n5. Otherwise the value is rendered as `String(value)`\n\n## See also\n\n- [Cell components](./cell-components.md) — when `format` is not enough.\n- [`cell-formatting.ts`](../../../packages/sv-grid-community/src/cell-formatting.ts)\n"
234
+ },
235
+ {
236
+ "slug": "help/cells/tooltips",
237
+ "path": "docs/help/cells/tooltips.md",
238
+ "title": "Tooltips",
239
+ "markdown": "# Tooltips\n\nThere is no built-in tooltip API on `ColumnDef`. Use the standard `title`\nattribute, an accessible `<dialog>`, or any popover library — wired\nthrough a custom cell renderer.\n\n## With `title` (no JS, screen-reader friendly)\n\n```ts\n{\n field: 'description',\n cell: (ctx) => renderSnippet(EllipsisCell, {\n value: String(ctx.getValue() ?? ''),\n }),\n}\n```\n\n```svelte\n{#snippet EllipsisCell(p: { value: string })}\n <span title={p.value} class=\"block truncate\">{p.value}</span>\n{/snippet}\n```\n\n`title` is the safest default: screen readers announce it; mouse users see\na tooltip; keyboard users see it on focus (when wrapped in a focusable\nelement).\n\n## With a popover library\n\nPass a component instead of a snippet:\n\n```ts\nimport TooltipCell from './TooltipCell.svelte'\nimport { renderComponent } from 'sv-grid-community'\n\n{\n field: 'description',\n cell: (ctx) => renderComponent(TooltipCell, {\n value: String(ctx.getValue() ?? ''),\n tip: ctx.row.original.fullDescription,\n }),\n}\n```\n\n## Header tooltips\n\nThe same pattern, but via the `header:` field — see\n[Custom header components](../columns/custom-header-components.md).\n\n## Gotchas\n\n- The grid's column-menu popover and the cell-edit overlay use top-layer\n z-indices around 100. Your tooltip should be either lower (so it slides\n under those overlays when both open) or higher with a click-outside\n dismissal.\n- A long tooltip inside an Excel-style filter dropdown can occlude the\n filter input. Detach the tooltip from cells inside an open filter menu.\n\n## See also\n\n- [Cell components](./cell-components.md)\n"
240
+ },
241
+ {
242
+ "slug": "help/cells/view-refresh",
243
+ "path": "docs/help/cells/view-refresh.md",
244
+ "title": "View refresh",
245
+ "markdown": "# View refresh\n\nThe grid renders reactively — it does **not** have a `refresh()` method,\nbecause it doesn't need one. To make the grid re-display, change the data\nthat drives it.\n\n## Forcing a refresh\n\n| You want | Do this |\n| -------- | ------- |\n| Re-display every row | Reassign `data` to a new array (`rows = [...rows]`). |\n| Re-display one row | Mutate the row through a `$state` array, or call `api.setCellValue(...)`. |\n| Re-apply sort / filter / page | Update the controlled state slice (or reassign the data). |\n| Re-render a single cell | The grid's renderer keys on the cell's `cellId`. Changing the underlying value re-renders the cell. |\n\n## When the grid does NOT re-render\n\nIf you mutate a row object **deeply** without going through `$state` —\ne.g. `someExternalRef.salary = 50000` where `someExternalRef` is an object\nheld outside the grid — Svelte 5 will not know to update.\n\nTwo safe patterns:\n\n```svelte\n<script lang=\"ts\">\n let rows = $state<Person[]>(initial)\n\n // ✅ via $state proxy\n rows[0]!.salary = 50_000\n\n // ✅ via array reassignment\n rows = rows.map((r, i) => (i === 0 ? { ...r, salary: 50_000 } : r))\n\n // ✅ via API\n api?.setCellValue(0, 'salary', 50_000)\n</script>\n```\n\n## Refresh-after-async\n\nFor data fetched asynchronously, the array reassignment is the canonical\ntrigger:\n\n```svelte\n<script lang=\"ts\">\n let rows = $state<Person[]>([])\n $effect(() => { fetchRows().then((next) => (rows = next)) })\n</script>\n```\n\n## See also\n\n- [Row data](../rows/row-data.md)\n- [Accessing rows](../rows/accessing-rows.md)\n"
246
+ },
247
+ {
248
+ "slug": "help/columns/column-definitions",
249
+ "path": "docs/help/columns/column-definitions.md",
250
+ "title": "Column definitions",
251
+ "markdown": "# Column definitions\n\nA `ColumnDef` tells SvGrid how to read a value out of a row, how to render it,\nand which features apply to it.\n\n## Minimal\n\n```ts\nimport type { ColumnDef } from 'sv-grid-community'\n\ntype Person = { firstName: string; age: number; status: string }\n\nconst columns: ColumnDef<{}, Person>[] = [\n { field: 'firstName', header: 'First name' },\n { field: 'age', header: 'Age' },\n { field: 'status', header: 'Status' },\n]\n```\n\n## Properties\n\n| Property | Type | Purpose |\n| --- | --- | --- |\n| `id` | `string` | Stable column id. Required when you use `accessorFn` and no `field`. |\n| `field` | `keyof TData & string` | Reads `row[key]`. |\n| `accessorFn` | `(row) => unknown` | Computes the value. |\n| `header` | `string` \\| `(ctx) => unknown` | String, or a function returning a `renderSnippet` / `renderComponent`. |\n| `cell` | `(ctx) => unknown` | Same shape as `header`, for body cells. |\n| `footer` | `string` \\| `(ctx) => unknown` | Footer cell. |\n| `editorType` | `'text' \\| 'number' \\| 'date' \\| 'datetime' \\| 'checkbox'` | Inline editor type. |\n| `format` | `CellFormatConfig` | Built-in `number`, `currency`, `percent`, `date`, `datetime` formatters. |\n| `formatter` | `(ctx) => string` | Custom formatter — runs after `field` / `accessorFn`. |\n| `columns` | `ColumnDef[]` | Children — turns this column into a column **group**. |\n| `width` | `number` | Initial width in pixels (overrides the grid's `columnWidth`). |\n\nSee [`packages/sv-grid-community/src/core.ts`](../../../packages/sv-grid-community/src/core.ts).\n\n## Accessor vs. accessorFn\n\n`field` is the common case. Use `accessorFn` when the value is\ncomputed or comes from a nested object:\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n { field: 'firstName', header: 'First' },\n {\n id: 'fullName',\n header: 'Full name',\n accessorFn: (row) => `${row.firstName} ${row.lastName}`,\n },\n]\n```\n\nWhenever you use `accessorFn` you must supply an `id` — there is no string key\nto derive one from.\n\n## Format vs. formatter vs. cell\n\n| You want | Use |\n| -------- | --- |\n| Locale-aware number / currency / percent / date | `format` |\n| A custom string transformation | `formatter` |\n| Custom HTML (avatars, pills, progress, sparklines) | `cell` with `renderSnippet` |\n\n`format` is purely declarative and locale-aware — prefer it for anything\nnumeric or temporal:\n\n```ts\n{ field: 'salary', header: 'Salary',\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } }\n\n{ field: 'joinedAt', header: 'Joined',\n format: { type: 'date', pattern: 'y-m-d' } }\n\n{ field: 'utilization', header: 'Utilization',\n format: { type: 'percent', valueIsPercentPoints: true } } // 42 -> 42%\n```\n\n`formatter` runs after the accessor; the result is what gets displayed\n(and what gets copied to the clipboard during cell selection).\n\n`cell` is the most powerful — see [Cell components](../cells/cell-components.md).\n\n## TypeScript\n\nPass the row type as the second generic; the column's `field` is then\nchecked against the row's keys:\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n { field: 'firstName' }, // ✅\n // { field: 'first_name' } // ✗ compile error\n]\n```\n\nThe first generic is the **feature set** — derive it from `tableFeatures` so\nfeature-specific column properties light up:\n\n```ts\nconst features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\ntype Features = typeof features\n\nconst columns: ColumnDef<Features, Person>[] = [/* … */]\n```\n\n## See also\n\n- [Updating definitions](./updating-definitions.md)\n- [Column state](./column-state.md)\n- [Custom header components](./custom-header-components.md)\n- Example: [`02-sort-filter-paginate.svelte`](../../../examples/src/demos/02-sort-filter-paginate.svelte)\n"
252
+ },
253
+ {
254
+ "slug": "help/columns/column-groups",
255
+ "path": "docs/help/columns/column-groups.md",
256
+ "title": "Column groups",
257
+ "markdown": "# Column groups\n\nA column group is a `ColumnDef` whose `columns` array contains children.\nThe parent renders a spanning header above its children.\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n { field: 'firstName', header: 'First name' },\n { field: 'lastName', header: 'Last name' },\n {\n id: 'compensation',\n header: 'Compensation',\n columns: [\n { field: 'salary', header: 'Salary',\n format: { type: 'currency', currency: 'USD' } },\n { field: 'bonus', header: 'Bonus',\n format: { type: 'currency', currency: 'USD' } },\n ],\n },\n]\n```\n\nRendered as:\n\n```\n| | Compensation |\n| First | Last | Salary | Bonus |\n```\n\n## How it works\n\n- The grid walks the column tree once, producing two header groups —\n the parent row and the leaf row.\n- Each parent header gets a `colSpan` equal to the count of leaf descendants\n it has.\n- The cell body only renders leaves.\n\n## Nested groups\n\nGroups can nest arbitrarily. The grid emits one header row per depth level:\n\n```ts\n{\n header: 'Q1',\n columns: [\n { header: 'Jan', field: 'jan' },\n { header: 'Feb', field: 'feb' },\n { header: 'Mar', field: 'mar' },\n ],\n},\n{\n header: 'Q2',\n columns: [/* … */],\n},\n```\n\n## Group with a custom header\n\nThe same `header: (ctx) => renderSnippet(...)` pattern from\n[custom header components](./custom-header-components.md) works for group\nheaders. The `ctx.header.colSpan` value will be the rendered span.\n\n## Gotchas\n\n- A group needs an `id` (or a string `header`) — the grid uses it to give the\n parent header a stable DOM id.\n- Hidden columns (`api.setColumnVisible(id, false)`) shrink their group's\n `colSpan` automatically.\n- A group cannot be sorted or filtered — only its leaves can.\n\n## See also\n\n- [Column definitions](./column-definitions.md)\n- [Custom header components](./custom-header-components.md)\n"
258
+ },
259
+ {
260
+ "slug": "help/columns/column-headers",
261
+ "path": "docs/help/columns/column-headers.md",
262
+ "title": "Column headers — styling & height",
263
+ "markdown": "# Column headers — styling & height\n\nHeaders are rendered as `<th>` inside a `<thead>` with `role=\"row\"`. Style\nthem with regular CSS — there are no header-specific Svelte props.\n\n## Set header text\n\n```ts\n{ field: 'firstName', header: 'First name' }\n```\n\nFor computed headers, pass a function returning a `renderSnippet` /\n`renderComponent` — see [Custom header components](./custom-header-components.md).\n\n## Header height\n\nThere is no `headerHeight` prop. Header height is determined by content +\npadding. Override via CSS:\n\n```css\ntable[role='grid'] thead th {\n height: 40px;\n padding: 0.4rem 0.6rem;\n}\n```\n\nIf you set a fixed virtualizer row height (`rowHeight={36}` on `<SvGrid>`)\nthe header is **independent** of that — the virtualizer measures it once\nat mount to compute the visible viewport.\n\n## Header colour, weight, alignment\n\nThe grid leans on the gallery's tokenised CSS:\n\n```css\ntable[role='grid'] thead tr {\n background: var(--sg-header-bg);\n color: var(--sg-header-fg);\n}\n\ntable[role='grid'] th {\n border: 1px solid var(--sg-border);\n font-weight: 600;\n text-align: left;\n}\n```\n\nSet those custom properties at `:root`, on an ancestor, or directly on the\ngrid host to change the look.\n\n## Sortable header indicator\n\nWhen `rowSortingFeature` is registered, sortable headers gain a click handler\nand `aria-sort` is updated. SvGrid renders the asc/desc arrow itself; to\nrestyle, target the inner span:\n\n```css\ntable[role='grid'] th [data-sort-indicator] {\n opacity: 0.6;\n}\ntable[role='grid'] th[aria-sort] [data-sort-indicator] {\n opacity: 1;\n}\n```\n\n## See also\n\n- [Custom header components](./custom-header-components.md)\n- [Column groups](./column-groups.md)\n"
264
+ },
265
+ {
266
+ "slug": "help/columns/column-moving",
267
+ "path": "docs/help/columns/column-moving.md",
268
+ "title": "Column moving",
269
+ "markdown": "# Column moving\n\nThe display order of columns is the **array order** of the `columns` prop.\nTo reorder columns, reassign the prop.\n\n```svelte\n<script lang=\"ts\">\n let columns = $state<ColumnDef<{}, Person>[]>([\n { field: 'firstName', header: 'First name' },\n { field: 'lastName', header: 'Last name' },\n { field: 'age', header: 'Age' },\n ])\n\n function swap(i: number, j: number) {\n const next = [...columns]\n ;[next[i], next[j]] = [next[j]!, next[i]!]\n columns = next\n }\n</script>\n\n<button onclick={() => swap(0, 1)}>Swap first two</button>\n<SvGrid {columns} {data} features={{}} />\n```\n\n## Drag-to-reorder\n\nA header drag-and-drop reorder UX is **not built in** as of writing —\ncolumns reorder only through array reassignment. To add it, attach\nHTML5 DnD handlers to the header cell and reorder the array on drop. A\n~30-line implementation looks like:\n\n```svelte\n<script lang=\"ts\">\n let dragging = $state<string | null>(null)\n\n function onDragStart(e: DragEvent, id: string) {\n dragging = id\n e.dataTransfer?.setData('text/plain', id)\n }\n\n function onDrop(e: DragEvent, targetId: string) {\n if (!dragging || dragging === targetId) return\n const next = [...columns]\n const from = next.findIndex((c) => (c.id ?? c.field) === dragging)\n const to = next.findIndex((c) => (c.id ?? c.field) === targetId)\n const [moved] = next.splice(from, 1)\n next.splice(to, 0, moved!)\n columns = next\n dragging = null\n }\n</script>\n```\n\nWire these to the grid's headers by using a custom header component\n(see [Custom header components](./custom-header-components.md)).\n\n## Pinned columns\n\nThe grid reorders pinned columns into left and right blocks regardless of\ntheir position in the array. Pinning beats array order; within each block\nthe array order is preserved.\n\n## Gotchas\n\n- A drag library will conflict with the resize handle on the right edge of\n each header. Either use the left-half of the header as the drag handle,\n or hold a modifier (e.g. `Alt`) to disambiguate.\n\n## See also\n\n- [Column pinning](./column-pinning.md)\n- [Updating definitions](./updating-definitions.md)\n"
270
+ },
271
+ {
272
+ "slug": "help/columns/column-pinning",
273
+ "path": "docs/help/columns/column-pinning.md",
274
+ "title": "Column pinning",
275
+ "markdown": "# Column pinning\n\nPinning sticks a column to the **left** or **right** edge of the viewport so\nit does not scroll horizontally with the rest.\n\n## Through the column menu\n\nEvery column header has a menu (the `⋮` button). The menu has \"Pin left\" /\n\"Pin right\" / \"Unpin\" items.\n\n## Programmatically\n\nThere is no public setter on `SvGridApi` for pinning today. To control it\nfrom outside the grid you have two options:\n\n1. **Initial state** — set the grid's `state.columnPinning` slice through\n the controlled state mechanism (see [Column state](./column-state.md)).\n2. **Bypass** — wrap the grid in your own component, hold the pinning state\n alongside, and re-render. Track the user's column-menu actions if you\n need to round-trip — these dispatch state updates inside the grid.\n\nA programmatic-pin setter on `SvGridApi` is on the\n[gap list](../missing-features.md).\n\n## Rendering\n\n- Pinned-left columns sit at the start of the visible row, with `position:\n sticky; left: <offset>px; z-index: 3`.\n- Pinned-right columns sit at the end of the visible row, with `position:\n sticky; right: <offset>px; z-index: 3`.\n- Offsets cascade — second-left column sits at the cumulative width of\n the first.\n\n## Multiple pinned columns\n\nMultiple pins are stacked in the order they were pinned. The first-pinned\ncolumn is the outermost.\n\n## Gotchas\n\n- Pinning **plus** column virtualization is supported, but the pinned\n columns are always rendered (they never enter the virtualized window).\n- If you have so many pinned columns that they exceed the viewport width\n there is no horizontal scrollbar within the pinned regions — the user\n loses access to the non-pinned middle. Pin only \"anchor\" columns\n (identifier, action, status) and keep the count single-digit.\n\n## See also\n\n- [Column state](./column-state.md)\n- [Missing features](../missing-features.md) — `setColumnPinning` on `SvGridApi`.\n"
276
+ },
277
+ {
278
+ "slug": "help/columns/column-sizing",
279
+ "path": "docs/help/columns/column-sizing.md",
280
+ "title": "Column sizing",
281
+ "markdown": "# Column sizing\n\nEach column has a pixel width. The default for all columns is the grid's\n`columnWidth` prop (default ~140 px); each column can override via its\n`width` field.\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n { field: 'firstName', header: 'First name', width: 150 },\n { field: 'department', header: 'Department', width: 180 },\n { field: 'salary', header: 'Salary', width: 120 },\n]\n```\n\n```svelte\n<SvGrid {columns} {data} features={{}} columnWidth={140} />\n```\n\n## User resizing\n\nEvery column has a resize handle on its right edge. Drag to widen / narrow;\nthe minimum is 40 px. Resizes are stored per column id inside the grid\ncomponent.\n\nThere is no opt-out today — if you do not want users to resize a column,\noverlay your own pointer-blocking element on the header or wrap in CSS:\n\n```css\ntable[role='grid'] th[data-col-id=\"firstName\"] [data-resize-handle] {\n pointer-events: none;\n}\n```\n\n## Auto-fit\n\nNot built in. If you need it, listen for window resize on the grid host and\nrecompute each column's `width` so they sum to the viewport width, then pass\nthe updated `columns` array.\n\n## Column virtualization\n\nWith many columns, enable column virtualization so only the visible columns\nrender:\n\n```svelte\n<SvGrid\n {columns}\n {data}\n features={{}}\n columnVirtualization={true}\n columnWidth={120}\n columnOverscan={3}\n/>\n```\n\nSee [examples/src/demos/06-large-dataset.svelte](../../../examples/src/demos/06-large-dataset.svelte)\nfor a 100-column virtualized grid.\n\n## Gotchas\n\n- A `width` set in the column def is an **initial** width. Once a user has\n resized, the override on disk wins. The library does not expose the\n current widths to outside code; if you need to persist them, file an\n issue (or PR) for `getColumnWidths()` on `SvGridApi`.\n\n## See also\n\n- [Column moving](./column-moving.md)\n- [Column pinning](./column-pinning.md)\n"
282
+ },
283
+ {
284
+ "slug": "help/columns/column-spanning",
285
+ "path": "docs/help/columns/column-spanning.md",
286
+ "title": "Column spanning",
287
+ "markdown": "# Column spanning\n\n\"Column spanning\" lets a single body cell span across **multiple columns** —\nuseful for full-width subtotals, group banner rows, or notes embedded inside\na wide grid.\n\n## Status\n\nThis is **not yet built in** to the community grid. There is no `colSpan`\nfield on `ColumnDef` or `CellContext`.\n\nThere are two close-enough workarounds:\n\n## 1. Full-width \"row banner\" via grouping\n\nIf your span semantics are \"render an aggregate above each group\", the\n[grouping](../rows/row-data.md#grouping) pipeline gives you a group row\nthat fills the row width via the group label column. See\n[examples/src/demos/07-grouping-aggregation.svelte](../../../examples/src/demos/07-grouping-aggregation.svelte).\n\n## 2. Custom cell with `position: absolute`\n\nIf you need an irregular full-width content cell inside an otherwise normal\nrow, render a regular cell whose content spills across columns:\n\n```svelte\n{#snippet Banner(p: { row: Row })}\n <span class=\"absolute left-0 right-0 px-2 bg-yellow-100\">\n {p.row.note}\n </span>\n{/snippet}\n```\n\nYou'll need a CSS contortion to disable borders on the covered cells. This\nis fragile — only use it for one-off rows like \"no results\" placeholders.\n\n## See also\n\n- [Row spanning](../rows/row-spanning.md) — the row-side analogue, also not built in.\n- [Missing features](../missing-features.md)\n"
288
+ },
289
+ {
290
+ "slug": "help/columns/column-state",
291
+ "path": "docs/help/columns/column-state.md",
292
+ "title": "Column state",
293
+ "markdown": "# Column state\n\n\"Column state\" is the bag of per-column settings that change at runtime:\nvisibility, width, pinning, sort, filter. SvGrid keeps these as separate\nstate slices inside the grid instance.\n\n## Slices\n\n| Slice | Where it lives | How to read | How to write |\n| ----- | -------------- | ----------- | ------------ |\n| Visibility | `<SvGrid>` internal | `api.isColumnVisible(id)` | `api.setColumnVisible(id, visible)` |\n| Width | `<SvGrid>` internal (resize handles) | drag the right edge of a header | — |\n| Pinning | `<SvGrid>` internal | via column menu | via column menu |\n| Sort | `state.sorting` | `grid.getState().sorting` | `api.setSort(id, dir)` / `api.clearSort()` |\n| Filter | `state.columnFilters` | `grid.getState().columnFilters` | `api.setFilter(id, ...)` / `api.clearFilter(id)` |\n\n## Persisting state\n\nTo round-trip column state (e.g. through `localStorage` or the URL),\ncontrol the slices that matter to you:\n\n```svelte\n<script lang=\"ts\">\n import type { SortingState } from 'sv-grid-community'\n\n const KEY = 'people-grid-state'\n\n function loadState() {\n try { return JSON.parse(localStorage.getItem(KEY) ?? '{}') }\n catch { return {} }\n }\n const initial = loadState()\n\n let sorting = $state<SortingState>(initial.sorting ?? [])\n let columnFilters = $state(initial.columnFilters ?? [])\n\n $effect(() => {\n localStorage.setItem(KEY, JSON.stringify({ sorting, columnFilters }))\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n state={{ sorting, columnFilters }}\n onSortingChange={(next) => (sorting = next)}\n onColumnFiltersChange={(next) => (columnFilters = next)}\n/>\n```\n\n## Resetting state\n\nThere is no single \"reset\" API today. To revert:\n\n- Sort: `api.clearSort()`\n- Filters: iterate the active list and call `api.clearFilter(id)`\n- Visibility: walk your columns and call `setColumnVisible(id, true)`\n\nWrap whichever subset you need into your own helper if you do this often.\n\n## Gotchas\n\n- Column **width** is currently tracked per column id inside the SvGrid\n component and is not exposed on `SvGridApi`. If you need to persist user\n resizes you'll have to read the cells after the grid renders or PR an\n accessor onto the API. See [missing-features.md](../missing-features.md).\n- Column **pinning** is controlled by the column menu; there is no public\n setter on the API today. Same caveat as above.\n\n## See also\n\n- [Column moving](./column-moving.md)\n- [Column pinning](./column-pinning.md)\n- [Updating definitions](./updating-definitions.md)\n"
294
+ },
295
+ {
296
+ "slug": "help/columns/custom-header-components",
297
+ "path": "docs/help/columns/custom-header-components.md",
298
+ "title": "Custom header components",
299
+ "markdown": "# Custom header components\n\nThe `header` field on a `ColumnDef` accepts a function that returns either\na `renderSnippet(...)` or `renderComponent(...)`. The function receives a\n`HeaderContext` so it can access the column, header, and grid.\n\n## With a snippet\n\n```svelte\n<script lang=\"ts\">\n import { renderSnippet, type ColumnDef } from 'sv-grid-community'\n\n const columns: ColumnDef<{}, Person>[] = [\n {\n field: 'salary',\n header: (ctx) => renderSnippet(SalaryHeader, { sorted: ctx.column.getIsSorted() }),\n format: { type: 'currency', currency: 'USD' },\n },\n ]\n</script>\n\n{#snippet SalaryHeader(p: { sorted: false | 'asc' | 'desc' })}\n <span class=\"inline-flex items-center gap-1\">\n <span>💰 Salary</span>\n {#if p.sorted === 'asc'}↑{:else if p.sorted === 'desc'}↓{/if}\n </span>\n{/snippet}\n```\n\n## With a component\n\n```ts\nimport HeaderWithIcon from './HeaderWithIcon.svelte'\nimport { renderComponent } from 'sv-grid-community'\n\nconst columns = [\n {\n field: 'status',\n header: () => renderComponent(HeaderWithIcon, { icon: 'flag', label: 'Status' }),\n },\n]\n```\n\n## HeaderContext\n\nThe argument passed to your header callback exposes:\n\n```ts\ntype HeaderContext<TData> = {\n header: Header<TData>\n column: Column<TData>\n table: SvGrid<TData>\n}\n\n// Column gives you sort state, filter capability, and the toggle handler:\nctx.column.getCanSort()\nctx.column.getIsSorted() // false | 'asc' | 'desc'\nctx.column.getToggleSortingHandler() // () => void\n```\n\n## When to use it\n\nAnything that needs more than a string belongs here — multi-line headers,\nfilter icons inside the header, units, tooltips, custom sort indicators,\na \"select all\" checkbox in a leading column.\n\n## Gotchas\n\n- Wrap your snippet output in **inline-level** markup (`<span>`, `<div>` with\n `inline-flex`). The grid renders the result inside a `<th>`'s text node\n position — block layout will misalign with the sort indicator and\n pin-handle decorations the grid adds around it.\n- Snippet/component props are recomputed on every render. Keep them cheap.\n\n## See also\n\n- [Cell components](../cells/cell-components.md) — same API on the body side.\n"
300
+ },
301
+ {
302
+ "slug": "help/columns/updating-definitions",
303
+ "path": "docs/help/columns/updating-definitions.md",
304
+ "title": "Updating column definitions",
305
+ "markdown": "# Updating column definitions\n\nThere are two ways to change columns after the grid has mounted:\n\n## 1. Reassign the `columns` prop\n\n`<SvGrid columns={...}>` is reactive. Replace the array (or mutate a `$state`\narray) and the grid re-derives its internal columns.\n\n```svelte\n<script lang=\"ts\">\n let columns = $state<ColumnDef<{}, Person>[]>([\n { field: 'firstName', header: 'First name' },\n { field: 'age', header: 'Age' },\n ])\n\n function addCountry() {\n columns = [...columns, { field: 'country', header: 'Country' }]\n }\n</script>\n\n<button onclick={addCountry}>+ Country</button>\n<SvGrid {columns} data={rows} features={{}} />\n```\n\n## 2. Use the imperative API\n\nThe wrapper exposes mutators via `onApiReady`:\n\n```svelte\n<script lang=\"ts\">\n let api: SvGridApi<{}, Person> | null = $state(null)\n</script>\n\n<SvGrid {columns} data={rows} features={{}} onApiReady={(next) => (api = next)} />\n\n<button onclick={() => api?.addColumn({ field: 'country', header: 'Country' })}>\n + Country\n</button>\n```\n\nAvailable column mutators on `SvGridApi`:\n\n| Method | What it does |\n| ------ | ------------ |\n| `addColumn(col, position?)` | Insert one column. `position` is `'left' \\| 'right' \\| number` (default `'right'`). |\n| `addColumns(cols, position?)` | Insert many. |\n| `removeColumn(id)` | Remove by column id (or field when no `id`). |\n| `setColumnVisible(id, visible)` | Show / hide. |\n| `isColumnVisible(id)` | Read visibility. |\n\nThe imperative path is the right choice when the column-change initiator is\n**outside** the parent that owns the `columns` array — e.g. a toolbar\ncomponent that doesn't know about the data source.\n\n## What is preserved when columns change\n\nWhen you add or remove a column:\n\n- Sort state survives if the sorted column is still present.\n- Filter state for the removed column is discarded.\n- Active-cell focus is clamped into bounds.\n- Column widths set by the user via resize handles are preserved by column id.\n- Pinning is preserved by column id.\n\nWhen you **reorder** columns by reassigning the array, the grid renders them\nin the new order; pinned-left and pinned-right groups retain their order\nrelative to themselves.\n\n## Gotchas\n\n- Anything that captures `ctx.column.columnDef` inside a `cell` callback will\n see the **new** column def after a swap. Don't cache it.\n- If you reassign the entire `columns` array on every render, you'll pay the\n cost of re-deriving headers each time. Memoise it (build once with\n `$state.raw` or a one-time IIFE) for hot-loop components.\n\n## See also\n\n- [Column state](./column-state.md)\n- [Column moving](./column-moving.md)\n"
306
+ },
307
+ {
308
+ "slug": "help/editing/edit-components",
309
+ "path": "docs/help/editing/edit-components.md",
310
+ "title": "Edit components",
311
+ "markdown": "# Edit components\n\nThe grid ships with five inline editors. Each is selected by the column's\n`editorType`:\n\n| `editorType` | DOM element | Notes |\n| ------------ | ----------- | ----- |\n| `'text'` | `<input type=\"text\">` | default |\n| `'number'` | `<input type=\"number\">` | parsed with `parseEditorValue('number', ...)` |\n| `'date'` | `<input type=\"date\">` | round-trips to ISO `YYYY-MM-DD` |\n| `'datetime'` | `<input type=\"datetime-local\">` | round-trips to ISO 8601 |\n| `'checkbox'` | `<input type=\"checkbox\">` | toggled on `Enter` / `Space` |\n\nThe editor renders **inside the cell** — same width, same row height,\nzero border. See [Styling cells → edit-mode cell](../cells/styling-cells.md#edit-mode-cell).\n\n## Custom editor\n\nThere is no `cellEditor` field on `ColumnDef` today and no way to plug in\na third-party component as the inline editor. To approximate one:\n\n1. Render the column read-only with a custom `cell` callback.\n2. Open your own popover on click / `F2`.\n3. Write back through `api.setCellValue(rowIndex, columnId, value)`.\n\n```svelte\n{#snippet StatusCell(p: { row: Person })}\n <button type=\"button\" onclick={() => openStatusEditor(p.row)}>\n {p.row.status}\n </button>\n{/snippet}\n```\n\nA first-class `cellEditor` plug-in slot is on the\n[gap list](../missing-features.md).\n\n## Conditional editability\n\nThere is no `editable: (row) => boolean` callback. Closest approximation:\nswap the column between an editable and a read-only version by reassigning\n`columns`.\n\n## See also\n\n- [Provided editors](./provided-editors.md)\n- [Cell components](../cells/cell-components.md)\n- [Custom column filters](../filtering/custom-column-filters.md) — same shape, filter side.\n"
312
+ },
313
+ {
314
+ "slug": "help/editing/full-row",
315
+ "path": "docs/help/editing/full-row.md",
316
+ "title": "Full-row editing",
317
+ "markdown": "# Full-row editing\n\n\"Full-row editing\" means the user opens an entire row for edit (every\neditable cell becomes an editor simultaneously) and commits all changes\nat once.\n\n## Status\n\nThis is **not** built in. SvGrid edits one cell at a time.\n\n## Workaround — overlay form\n\nTrigger an edit form from a row action and write changes back via\n`api.setCellValue` for each field:\n\n```svelte\n<script lang=\"ts\">\n let editing = $state<Person | null>(null)\n\n function commit(updated: Person) {\n if (!api || !editing) return\n const idx = api.getData().findIndex((r) => r.id === editing!.id)\n if (idx === -1) return\n for (const key of Object.keys(updated) as Array<keyof Person>) {\n api.setCellValue(idx, key, updated[key])\n }\n editing = null\n }\n</script>\n\n{#if editing}\n <dialog open>\n <!-- a regular form bound to a copy of `editing` -->\n </dialog>\n{/if}\n\n<SvGrid {data} {columns} features={features}\n enableInlineEditing={false}\n onApiReady={(next) => (api = next)} />\n```\n\nThis is often *better UX* than full-row editing for keyboard-heavy users —\nthe form can have proper field labels and a save button.\n\n## Tracked at\n\n[Missing features](../missing-features.md) — full-row editing as a built-\nin mode.\n\n## See also\n\n- [Provided editors](./provided-editors.md)\n- [Saving values](./saving-values.md)\n"
318
+ },
319
+ {
320
+ "slug": "help/editing/overview",
321
+ "path": "docs/help/editing/overview.md",
322
+ "title": "Editing — overview",
323
+ "markdown": "# Editing — overview\n\nInline editing is a single prop on `<SvGrid>`:\n\n```svelte\n<SvGrid {data} {columns} features={features} enableInlineEditing={true} />\n```\n\nTo make a specific column editable, give it an `editorType`:\n\n```ts\nconst columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n { field: 'active', header: 'Active', editorType: 'checkbox' },\n]\n```\n\nA column with no `editorType` is **read-only** even when\n`enableInlineEditing={true}`.\n\n## How a user edits\n\n| Action | Keys | Outcome |\n| ------ | ---- | ------- |\n| Enter edit mode | `Enter` / `F2` / double-click | Editor opens on the active cell |\n| Commit | `Enter` / `Tab` | Saves the new value |\n| Cancel | `Esc` | Discards |\n| Move to next field while editing | `Tab` / `Shift+Tab` | Commits and re-enters edit on the neighbour |\n\n## What gets saved\n\nWhen the user commits, the grid:\n\n1. Parses the editor's string value through `parseEditorValue` for the\n column's `editorType`.\n2. Writes the parsed value into the grid's internal data copy.\n3. Fires `onCellValueChange` with `{ rowIndex, columnId, oldValue, newValue, row }`.\n4. Fires a re-render.\n\nTo round-trip edits to your source, attach a callback:\n\n```svelte\n<SvGrid\n {data} {columns} features={features}\n enableInlineEditing={true}\n onCellValueChange={(e) => savePersonField(e.row.id, e.columnId, e.newValue)}\n/>\n```\n\nSee [Saving values](./saving-values.md) for the full patterns\n(per-edit, batch-from-snapshot, cascade recompute).\n\n## See also\n\n- [Start / stop editing](./start-stop-editing.md)\n- [Parsing values](./parsing-values.md)\n- [Saving values](./saving-values.md)\n- [Provided editors](./provided-editors.md)\n- [Validation](./validation.md)\n"
324
+ },
325
+ {
326
+ "slug": "help/editing/parsing-values",
327
+ "path": "docs/help/editing/parsing-values.md",
328
+ "title": "Parsing values",
329
+ "markdown": "# Parsing values\n\nWhen the user commits an edit, the editor's DOM value (a string for text /\nnumber / date inputs, a boolean for checkboxes) is parsed by\n`parseEditorValue` into the canonical value for the column's type.\n\n```ts\nimport { parseEditorValue } from 'sv-grid-community'\n\nparseEditorValue('text', 'Ada') // 'Ada'\nparseEditorValue('number', '42') // 42\nparseEditorValue('number', '4.5') // 4.5\nparseEditorValue('number', 'NaN') // null (rejected — caller decides)\nparseEditorValue('number', '') // null\nparseEditorValue('date', '2026-05-27') // '2026-05-27T00:00:00.000Z'\nparseEditorValue('datetime', '2026-05-27T14:32') // '2026-05-27T14:32:00.000Z'\nparseEditorValue('checkbox', 'true') // true\nparseEditorValue('checkbox', true) // true\n```\n\nThe full source is short and worth reading: [`cell-editors.ts`](../../../packages/sv-grid-community/src/editors/cell-editors.ts).\n\n## What \"null\" means\n\n`parseEditorValue` returns `null` to signal \"could not parse\". The grid\ntreats `null` as an empty value and writes it into the cell. If you want\n**invalid input rejected** (the value reverts to its pre-edit state),\nintercept before the write — see [Validation](./validation.md).\n\n## Custom parsing\n\nThere is no per-column `valueParser` field on `ColumnDef` today. If you\nneed custom parsing (e.g. accept \"$42,500\" and turn it into `42500`), you\nhave two paths:\n\n1. Post-process inside `cell` and store the raw display string.\n2. Diff `api.getData()` against your own snapshot after each commit and\n normalise values you want canonicalised.\n\nA per-column `valueParser` is on the\n[gap list](../missing-features.md).\n\n## See also\n\n- [Saving values](./saving-values.md)\n- [Validation](./validation.md)\n"
330
+ },
331
+ {
332
+ "slug": "help/editing/provided-editors",
333
+ "path": "docs/help/editing/provided-editors.md",
334
+ "title": "Provided cell editors",
335
+ "markdown": "# Provided cell editors\n\n## Text editor — `editorType: 'text'`\n\n`<input type=\"text\">`. Accepts any string. On commit the raw value is\nstored.\n\n```ts\n{ field: 'firstName', header: 'First', editorType: 'text' }\n```\n\nA separate \"large text\" editor (multi-line `<textarea>`) is **not** built\nin. To approximate, render a custom popover via the [edit components](./edit-components.md) workaround.\n\n## Number editor — `editorType: 'number'`\n\n`<input type=\"number\">` with browser-native increment buttons.\nNon-numeric input is rejected at commit (`parseEditorValue` returns\n`null` and the cell stays at its previous value).\n\n```ts\n{ field: 'age', header: 'Age', editorType: 'number' }\n\n// often paired with a display format:\n{\n field: 'salary', header: 'Salary',\n editorType: 'number',\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n}\n```\n\n## Date editor — `editorType: 'date'`\n\n`<input type=\"date\">`. The value is stored as ISO `YYYY-MM-DD`.\n\n```ts\n{\n field: 'joinedAt', header: 'Joined',\n editorType: 'date',\n format: { type: 'date', pattern: 'y-m-d' },\n}\n```\n\n## Datetime editor — `editorType: 'datetime'`\n\n`<input type=\"datetime-local\">`. The value is stored as ISO 8601 with a Z\nsuffix.\n\n```ts\n{\n field: 'updatedAt', header: 'Updated',\n editorType: 'datetime',\n format: { type: 'datetime', pattern: 'medium' },\n}\n```\n\n## Checkbox editor — `editorType: 'checkbox'`\n\n`<input type=\"checkbox\">`. The value is stored as `true` / `false`.\n\n```ts\n{ field: 'active', header: 'Active', editorType: 'checkbox' }\n```\n\n## Select editor\n\n**Not built in**. The closest workaround:\n\n1. Render the read-only cell with a custom `cell` callback that opens a\n `<dialog>` or popover.\n2. On select, write back via `api.setCellValue(...)`.\n\nA first-class select editor — and its rich-select cousin with async\noptions — are on the [gap list](../missing-features.md).\n\n## See also\n\n- [Cell data types](../cells/cell-data-types.md)\n- [Parsing values](./parsing-values.md)\n- [Validation](./validation.md)\n"
336
+ },
337
+ {
338
+ "slug": "help/editing/saving-values",
339
+ "path": "docs/help/editing/saving-values.md",
340
+ "title": "Saving values",
341
+ "markdown": "# Saving values\n\nWhen the user commits an edit, the new value is written into the grid's\n**internal data copy**. The grid does **not** mutate the array you passed\nin via the `data` prop - it keeps its own working copy so an undo / cancel\nis possible without touching your state.\n\nTo round-trip edits back to your source there are two patterns.\n\n## Pattern A - `onCellValueChange` (recommended)\n\nThe wrapper fires `onCellValueChange` whenever an inline edit commits.\nThe payload contains everything you need to forward the edit to a server,\nupdate a cached aggregate, or push to an undo stack:\n\n```svelte\n<script lang=\"ts\">\n type Event = {\n rowIndex: number\n columnId: string\n oldValue: unknown\n newValue: unknown\n row: Person\n }\n\n function onCellValueChange(event: Event) {\n // 1. ship the change\n savePersonField(event.row.id, event.columnId, event.newValue)\n // 2. update derived data (totals, dependencies, etc.)\n // see the cascade-editing demo for the full pattern\n }\n</script>\n\n<SvGrid\n {data}\n {columns}\n features={features}\n enableInlineEditing={true}\n onCellValueChange={onCellValueChange}\n/>\n```\n\nThe wrapper has already written the parsed value into the row by the\ntime the callback fires, so `event.row` reflects the post-edit state.\nThe [`18-cascade-editing` demo](../../../examples/src/demos/18-cascade-editing.svelte) wires this\ninto a recompute pipeline.\n\n## Pattern B - read a snapshot from the API\n\nUseful for batch saves, \"click Save to commit\" UIs, or diffing against a\nbefore-snapshot:\n\n```svelte\n<script lang=\"ts\">\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n\n function captureChanges() {\n if (!api) return\n const after = api.getData()\n // diff against your before-snapshot, send to server, etc.\n }\n</script>\n\n<SvGrid {data} {columns} features={features}\n enableInlineEditing\n onApiReady={(next) => (api = next)} />\n\n<button onclick={captureChanges}>Save</button>\n```\n\nThe [`05-inline-editing` demo](../../../examples/src/demos/05-inline-editing.svelte) shows\nthis pattern with dirty-cell tracking against an `initial` snapshot.\n\n## Sync data both ways\n\nReplacing the `data` prop forces the grid to re-read it. To mirror the\ngrid's internal copy back into a parent `$state` so other UI can react,\nuse `onCellValueChange`:\n\n```svelte\n<script lang=\"ts\">\n let rows = $state<Person[]>([...])\n function onCellValueChange(e: { rowIndex: number; row: Person }) {\n rows = rows.map((r, i) => (i === e.rowIndex ? e.row : r))\n }\n</script>\n\n<SvGrid data={rows} {columns} features={features}\n enableInlineEditing\n onCellValueChange={onCellValueChange} />\n```\n\n## See also\n\n- [Parsing values](./parsing-values.md)\n- [Validation](./validation.md)\n- [Undo / redo](./undo-redo.md)\n"
342
+ },
343
+ {
344
+ "slug": "help/editing/start-stop-editing",
345
+ "path": "docs/help/editing/start-stop-editing.md",
346
+ "title": "Start / stop editing",
347
+ "markdown": "# Start / stop editing\n\n## Start\n\n| Trigger | Behaviour |\n| ------- | --------- |\n| Double-click a cell | Opens the editor with all text selected. |\n| `Enter` on a focused cell | Same as double-click. |\n| `F2` on a focused cell | Opens the editor with the caret at the end (no text selection). |\n| Typing a character | Opens the editor and replaces the value with the typed character. |\n\nThe cell must be **focused** (the grid's active cell). Click any cell or\nuse the arrow keys to set focus.\n\nThe cell must also be **editable** — its column must have an\n`editorType`, and the grid must have `enableInlineEditing={true}`.\n\n## Stop\n\n| Trigger | Outcome |\n| ------- | ------- |\n| `Enter` | Commit. Move focus down one row. |\n| `Tab` | Commit. Move focus to the next editable cell in the same row. |\n| `Shift+Tab` | Commit. Move focus to the previous editable cell. |\n| `Esc` | Cancel. Revert to the pre-edit value. |\n| Click outside the cell | Commit. |\n\n## Programmatic start/stop\n\nThere is no public `api.startEditing(rowIndex, columnId)` or `stopEditing()`\non `SvGridApi` today. To force-edit a cell from outside, set the active\ncell (also not yet exposed on the public API) and dispatch a synthetic\n`F2` to the grid's root.\n\nThis is on the [gap list](../missing-features.md).\n\n## See also\n\n- [Validation](./validation.md)\n- [Provided editors](./provided-editors.md)\n"
348
+ },
349
+ {
350
+ "slug": "help/editing/undo-redo",
351
+ "path": "docs/help/editing/undo-redo.md",
352
+ "title": "Undo / redo",
353
+ "markdown": "# Undo / redo\n\nThere is **no built-in undo/redo stack** inside `<SvGrid>`. The grid emits\nno per-edit event for an external stack to subscribe to, so undo/redo has\nto be built on top of the data-snapshot approach.\n\n## Pattern — snapshot stack\n\n```svelte\n<script lang=\"ts\">\n type Snapshot = Person[]\n\n let rows = $state<Person[]>(initial)\n let past = $state<Snapshot[]>([])\n let future = $state<Snapshot[]>([])\n const LIMIT = 50\n\n // Whenever rows change because of an edit, push the previous state onto `past`.\n let last = JSON.stringify(rows)\n $effect(() => {\n const next = JSON.stringify(rows)\n if (next === last) return\n past = [...past.slice(-LIMIT), JSON.parse(last)]\n future = []\n last = next\n })\n\n function undo() {\n const prev = past.at(-1)\n if (!prev) return\n future = [rows.map((r) => ({ ...r })), ...future].slice(0, LIMIT)\n rows = prev.map((r) => ({ ...r }))\n past = past.slice(0, -1)\n last = JSON.stringify(rows)\n }\n\n function redo() {\n const next = future[0]\n if (!next) return\n past = [...past, rows.map((r) => ({ ...r }))].slice(-LIMIT)\n rows = next.map((r) => ({ ...r }))\n future = future.slice(1)\n last = JSON.stringify(rows)\n }\n</script>\n\n<svelte:window onkeydown={(e) => {\n const meta = e.ctrlKey || e.metaKey\n if (meta && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo() }\n if (meta && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) { e.preventDefault(); redo() }\n}} />\n\n<SvGrid data={rows} {columns} features={features} enableInlineEditing />\n```\n\n## Why JSON snapshots\n\nWithout a per-edit event from the grid, the safest store of \"previous\nstate\" is a deep copy of the row array. For a 5,000-row grid the snapshot\nis a few hundred KB — fine. For a 100,000-row grid, switch to a row-diff\nstack:\n\n```ts\ntype Diff = { rowId: string; column: string; before: unknown; after: unknown }\n```\n\n…and apply it during undo/redo. The per-edit event needed to compute that\ncleanly is on the [gap list](../missing-features.md).\n\n## Tracked at\n\n[Missing features](../missing-features.md) — first-class undo stack with\n`onCellValueChange` to drive it.\n\n## See also\n\n- [Saving values](./saving-values.md)\n- [demos/05-inline-editing.svelte](../../../examples/src/demos/05-inline-editing.svelte)\n"
354
+ },
355
+ {
356
+ "slug": "help/editing/validation",
357
+ "path": "docs/help/editing/validation.md",
358
+ "title": "Validation",
359
+ "markdown": "# Validation\n\nThere is no `validate(value)` callback on `ColumnDef` today. Validation\nhappens by intercepting committed edits and either accepting or reverting\nthem.\n\n## Built-in soft validation\n\n`parseEditorValue` already does light validation:\n\n- `number`: rejects non-finite results → returns `null`\n- `date` / `datetime`: rejects unparseable strings → returns `null`\n\nThe grid writes `null` into the cell when this happens. That is \"soft\"\nvalidation — the user sees the cell go blank rather than seeing their\ninput rejected with an explanation.\n\n## Hard validation (reject + revert)\n\nTo bounce the user back to the previous value with an explanation,\nmaintain your own snapshot and revert after the commit:\n\n```svelte\n<script lang=\"ts\">\n let api: SvGridApi<typeof features, Person> | null = $state(null)\n let initial = $state<Person[]>([])\n let error = $state<{ row: number; col: string; msg: string } | null>(null)\n\n function validateRow(row: Person): string | null {\n if (row.age < 0 || row.age > 130) return 'Age must be between 0 and 130.'\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(row.email)) return 'Invalid email.'\n return null\n }\n\n $effect(() => {\n if (!api) return\n const snap = api.getData()\n for (let i = 0; i < snap.length; i++) {\n const msg = validateRow(snap[i]!)\n if (msg) {\n // revert by writing back the original\n const original = initial[i]\n if (original) {\n for (const key of Object.keys(original) as Array<keyof Person>) {\n if ((snap[i] as any)[key] !== (original as any)[key]) {\n api!.setCellValue(i, key as string, (original as any)[key])\n }\n }\n }\n error = { row: i, col: '*', msg }\n return\n }\n }\n error = null\n initial = snap.map((r) => ({ ...r }))\n })\n</script>\n\n{#if error}\n <p class=\"text-rose-600\">Row {error.row + 1}: {error.msg}</p>\n{/if}\n\n<SvGrid {data} {columns} features={features} enableInlineEditing\n onApiReady={(next) => (api = next)} />\n```\n\nThis polling-based validator works but has obvious limits:\n\n- The validator runs on every reactive tick, not strictly on commit.\n- The user briefly sees the invalid value before it reverts.\n\nA per-column `validate(value, row, column)` returning `string | true` is\non the [gap list](../missing-features.md).\n\n## Inline error UI\n\nRender an asterisk / red border via a custom cell renderer that reads\nyour validation state map. See [Highlighting changes](../cells/highlighting-changes.md)\nfor the same pattern with a \"dirty\" indicator — substitute \"invalid\" for\n\"dirty\".\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"
360
+ },
361
+ {
362
+ "slug": "help/export",
363
+ "path": "docs/help/export.md",
364
+ "title": "Data export and printing",
365
+ "markdown": "# Data export and printing\n\nExport the grid to **Excel (xlsx)**, **PDF**, **CSV**, **TSV**, **HTML**,\nor open a printable view in a new window. Ships in the paid\n**[sv-grid-pro](https://www.npmjs.com/package/sv-grid-pro)** add-on; the\nCommunity build does not include these features.\n\n> See the live example: **21. Export + Print (Pro)** in the gallery.\n\n## What it is\n\n`sv-grid-pro` augments the `SvGridApi` you already get from\n`<SvGrid onApiReady>` with two methods:\n\n- `api.exportData({ format, filename?, columns?, rows?, pageOrientation? })`\n- `api.print({ title?, columns?, rows?, orientation? })`\n\nBoth methods default to **the currently displayed rows** — sort, filter,\nor paginate the grid, and the export reflects that view automatically.\n\n## When to use it\n\n- Reporting flows where users want to take the grid offline (spreadsheets,\n emailed PDFs).\n- Compliance / audit trails that require a printable artifact.\n- Quick CSV/TSV pulls for downstream pipelines.\n\nIf you only need machine-readable data, prefer CSV / TSV — they have no\npeer dependencies and produce the smallest files. Use xlsx / PDF only\nwhen the recipient expects formatted documents.\n\n## Minimal example\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature, type SvGridApi, type ColumnDef } from 'sv-grid-community'\n import { installPro, setLicenseKey, type ProGridApi } from 'sv-grid-pro'\n\n // Set the license key once at startup. Without a key, the feature still\n // works but the grid shows an \"unlicensed\" watermark and the console\n // emits a one-time nudge directing users to the pricing page.\n setLicenseKey('SVPRO-XXXX-XXXX-XXXX')\n\n const features = tableFeatures({ rowSortingFeature })\n\n type Order = { company: string; product: string; price: number }\n const rows: Order[] = [\n { company: 'ACME', product: 'Widget', price: 19.95 },\n { company: 'Globex', product: 'Gadget', price: 49.00 },\n ]\n const columns: ColumnDef<typeof features, Order>[] = [\n { field: 'company', header: 'Company' },\n { field: 'product', header: 'Product' },\n { field: 'price', header: 'Price', format: { type: 'currency', currency: 'USD' } },\n ]\n\n let api = $state<ProGridApi<typeof features, Order> | null>(null)\n\n function onReady(next: SvGridApi<typeof features, Order>) {\n api = installPro(next)\n }\n</script>\n\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'orders' })}>\n Export Excel\n</button>\n<button onclick={() => api?.exportData({ format: 'pdf', filename: 'orders', pageOrientation: 'landscape' })}>\n Export PDF\n</button>\n<button onclick={() => api?.print({ title: 'Orders' })}>\n Print\n</button>\n\n<SvGrid data={rows} columns={columns} features={features} onApiReady={onReady} />\n```\n\n## Install\n\n```bash\npnpm add sv-grid-pro\n# Optional - install only the peers you actually use:\npnpm add jszip # required for xlsx\npnpm add pdfmake # required for pdf\n```\n\nCSV, TSV, HTML, and Print have **no extra dependencies**. The peer\ndependencies are lazy-loaded only when you call the format that needs\nthem.\n\n## Licensing\n\n`sv-grid-pro` has a tiered license gate:\n\n| Key state | Behavior |\n| ------------------------------------------ | -------- |\n| No key set (`setLicenseKey()` not called) | Feature works. Grid shows an unlicensed watermark linking to jqwidgets.com; console.log emits a one-time nudge. |\n| Key doesn't start with `SVPRO-` | Throws — programmer error. |\n| Key is in the revoked list | Throws — contact support for a replacement. |\n| `SVPRO-DEV-...` or `SVPRO-EVAL-...` | Works. One-time console.info notice. No watermark. |\n| Any other `SVPRO-...` | Works silently. |\n\nBuy a production key at <https://sv-grid.dev/pricing> ($599 / developer /\nyear). `SVPRO-DEV-...` and `SVPRO-EVAL-...` keys cover local development\nand 14-day trials respectively.\n\n## Reference\n\n### `setLicenseKey(key: string): void`\n\nStores the key in module state. Call once at app startup (e.g. in\n`main.ts`). Subsequent calls overwrite.\n\n### `clearLicenseKey(): void` · `hasValidLicense(): boolean` · `dismissUnlicensedNudge(): void`\n\nProgrammatic helpers. `hasValidLicense()` is useful when you want UI to\nbranch on license status. `dismissUnlicensedNudge()` removes the\nwatermark and stops the MutationObserver — call it after setting a\nvalid key if you toggled the soft-gate during testing.\n\n### `installPro(api): ProGridApi`\n\nMutates the given `SvGridApi` to add `exportData` and `print`. Returns\nthe same object with the augmented type, so existing references keep\nworking.\n\n### `api.exportData(opts)` — `Promise<void>`\n\n| Option | Type | Default | Notes |\n| ----------------- | ----------------------------------------------------- | -------------------- | ----- |\n| `format` | `'xlsx' \\| 'pdf' \\| 'csv' \\| 'tsv' \\| 'html'` | required | `xlsx` needs `jszip`; `pdf` needs `pdfmake`. |\n| `filename` | `string` | `\"grid\"` | Extension is appended if missing. |\n| `columns` | `{ field: string; header?: string }[]` | every key of row[0] | Drives both column selection and header labels. |\n| `rows` | `ReadonlyArray<TData>` | `api.getDisplayedRows()` | Override to export the full dataset instead of the visible view. |\n| `pageOrientation` | `'portrait' \\| 'landscape'` | `\"portrait\"` | PDF only. |\n\nThrows on missing peer (`jszip` / `pdfmake`), revoked / malformed\nlicense, or empty result set. With no license set, it runs but the\ngrid is watermarked.\n\n### `api.print(opts?)` — `Promise<void>`\n\nOpens a new window with a paginated, printable HTML rendering of the\ngrid and triggers the browser print dialog.\n\n| Option | Type | Default |\n| ------------- | -------------------------------------- | ----------- |\n| `title` | `string` | `\"Grid\"` |\n| `columns` | `{ field: string; header?: string }[]` | all keys |\n| `rows` | `ReadonlyArray<TData>` | `api.getDisplayedRows()` |\n| `orientation` | `'portrait' \\| 'landscape'` | `\"portrait\"` |\n\nBrowsers may block the popup unless `print()` is called from a user\ngesture (a click handler is fine — automatic on-load print is not).\n\n## Gotchas\n\n- **Empty grids** — both `exportData` and `print` throw if there are no\n displayed rows. Catch the error and show a notice in the UI.\n- **Column ordering** — if you don't pass `columns`, the export uses\n `Object.keys(rows[0])` order. Pass `columns` explicitly when the row\n shape doesn't match the column order you want.\n- **Cell formatters** — only column-level format hints (date, number,\n currency) carry into xlsx / pdf. Custom snippet renderers are not\n serialized to file formats — provide a plain field for those rows\n instead.\n- **Print popup blocked** — `print()` resolves but the browser silently\n blocks the new window. Always trigger from a user click, and surface\n the thrown error.\n- **Bundle size** — the vendored exporter is ~50 KB minified. It is\n loaded lazily on first call so it does not bloat the initial bundle\n for users who never export.\n"
366
+ },
367
+ {
368
+ "slug": "help/filtering/applying-filters",
369
+ "path": "docs/help/filtering/applying-filters.md",
370
+ "title": "Applying filters",
371
+ "markdown": "# Applying filters\n\n\"Applying\" a filter means running it against the data. SvGrid applies\nfilters **eagerly** by default: every keystroke updates the row model and\nthe visible rows (with a 150ms debounce inside the column menu's value\ninput).\n\n## Apply on Enter (manual)\n\nIf you want the classic \"apply\"-button pattern (no filter runs until the\nuser confirms), control the filter state and only commit it on submit:\n\n```svelte\n<script lang=\"ts\">\n let pending = $state({ id: 'name', value: '' })\n let columnFilters = $state<Array<{ id: string; value: unknown }>>([])\n\n function commit() {\n if (!pending.value) {\n columnFilters = columnFilters.filter((c) => c.id !== pending.id)\n } else {\n columnFilters = [\n ...columnFilters.filter((c) => c.id !== pending.id),\n { id: pending.id, value: pending.value },\n ]\n }\n }\n</script>\n\n<input bind:value={pending.value} onkeydown={(e) => e.key === 'Enter' && commit()} />\n<button onclick={commit}>Apply</button>\n\n<SvGrid\n {data} {columns} features={features}\n filterMode=\"none\"\n state={{ columnFilters }}\n onColumnFiltersChange={(next) => (columnFilters = next)}\n/>\n```\n\nThe grid's own column menu does not have an \"Apply\" button — filters apply\nas the user types.\n\n## Apply against the server\n\nWhen data is server-side, filtering happens on the backend. Mirror the\nstate pattern but issue a request from the change handler. See\n[demos/09-server-side.svelte](../../../examples/src/demos/09-server-side.svelte)\nfor the canonical implementation with debounce + abort.\n\n## Apply once, then freeze\n\nTo take a snapshot:\n\n```ts\nconst filteredData = api.getData().filter(myPredicate)\n// pass filteredData into a separate <SvGrid> with filterMode=\"none\"\n```\n\nThis works when you want to render the filtered result inside an\nexport-friendly grid that's independent of the user's current filters.\n\n## See also\n\n- [Applying filters — server-side variant](../../getting-started.md#11-server-side-data)\n- [Filter API](./filter-api.md)\n"
372
+ },
373
+ {
374
+ "slug": "help/filtering/custom-column-filters",
375
+ "path": "docs/help/filtering/custom-column-filters.md",
376
+ "title": "Custom column filters",
377
+ "markdown": "# Custom column filters\n\nWhen the built-in operators and the set-filter pattern are not enough,\ntake over the filter pipeline.\n\n## Option A — supply a `filterFn`\n\nThe simplest extension: register your own filter function and reference it\nper column.\n\n```ts\n// types\ndeclare module 'sv-grid-community' {\n interface FilterFnsRegistry {\n inListCSV: (value: unknown, query: string) => boolean\n }\n}\n\n// register\nimport { filterFns } from 'sv-grid-community'\n;(filterFns as any).inListCSV = (value: unknown, query: string) => {\n const items = String(query).split(',').map((s) => s.trim().toLowerCase())\n return items.includes(String(value ?? '').toLowerCase())\n}\n\n// use\nconst columnFilters = [{ id: 'status', value: 'active,pending', fn: 'inListCSV' }]\n```\n\n`filterFns` is a plain object so monkey-patching works — but the\n`FilterFnsRegistry` module augmentation is what tells TypeScript about the\nnew key.\n\n## Option B - control the entire filter pipeline\n\nPass `externalFilter={true}` and feed the grid a pre-filtered array. The\ngrid still records the in-UI filter state (so the menu and chips light\nup correctly) but does **not** filter rows itself - you do, in response\nto the `onFiltersChange` callback. This is the path server-side data\nsources, large remote datasets, and tree data take.\n\n```svelte\n<script lang=\"ts\">\n let rawRows = $state<Person[]>([])\n let filters = $state<{ id: string; operator: string; value: string }[]>([])\n const filtered = $derived(myComplexFilter(rawRows, filters))\n</script>\n\n<SvGrid\n data={filtered}\n {columns}\n features={features}\n filterMode=\"menu\"\n externalFilter={true}\n onFiltersChange={(next) => (filters = next.columns)}\n/>\n```\n\nThe same pattern exists for sort (`externalSort` + `onSortingChange`)\nand works on the same grid - see the [server-side demo](../../../examples/src/demos/09-server-side.svelte).\n\nIf you only want a single pre-filtered array and don't need the grid's\nfilter UI at all, pass `filterMode=\"none\"` instead and skip the\ncallbacks entirely.\n\n## Option C — custom column UI\n\nRender a custom header (via `header: () => renderSnippet(...)`) that\nincludes your own filter widget. Update controlled state from the widget;\nthe grid will react. See [Custom header components](../columns/custom-header-components.md).\n\n## See also\n\n- [Filter API](./filter-api.md)\n- [Set filter](./set-filter.md)\n"
378
+ },
379
+ {
380
+ "slug": "help/filtering/date-filter",
381
+ "path": "docs/help/filtering/date-filter.md",
382
+ "title": "Date filter",
383
+ "markdown": "# Date filter\n\nA column with `editorType: 'date'` (or `'datetime'`) gets the **date**\nfilter operator set: `equals`, `lessThan`, `greaterThan`, `isBlank`.\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n {\n field: 'joinedAt',\n header: 'Joined',\n editorType: 'date',\n format: { type: 'date', pattern: 'y-m-d' },\n },\n]\n```\n\n## Value format\n\nStore dates as **ISO date strings** (`YYYY-MM-DD`) or as `Date`\nobjects. The grid compares them via `Date.parse()` so both forms work,\nbut stick to one for sort stability.\n\nFor `'datetime'`, use full ISO 8601: `2026-05-27T14:32:00Z`.\n\n## Date range\n\nTo filter by a date range from the header menu, combine `greaterThan` +\n`lessThan`. Through the headless `applyExcelFilter`, the `between`\noperator works:\n\n```ts\nimport { applyExcelFilter } from 'sv-grid-community'\n\napplyExcelFilter('2026-05-27', {\n id: 'joinedAt', operator: 'between',\n value: '2026-01-01', valueTo: '2026-12-31',\n})\n// → true\n```\n\n(Date `between` is inclusive at both ends.)\n\n## Today / yesterday / last 7 days\n\nNot built in. Apply via the imperative API:\n\n```ts\nfunction lastNDays(api: SvGridApi<{}, Person>, columnId: string, n: number) {\n const cutoff = new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10)\n api.setFilter(columnId, { operator: 'greaterThan', value: cutoff })\n}\n\nlastNDays(api, 'joinedAt', 7)\n```\n\n## Timezones\n\nThe grid does not adjust dates for the user's timezone. If you store\n`'2026-05-27'` and the user is in UTC-08, the cell displays as\n`2026-05-27` (no shift), and a `greaterThan: '2026-05-26'` filter matches.\nThat is usually what people want for **calendar** dates. For timezone-\nsensitive datetimes, store with `Z` suffix and use the `datetime` editor\ntype.\n\n## See also\n\n- [Filter conditions](./filter-conditions.md)\n- [Date editor](../editing/provided-editors.md#date-editor)\n"
384
+ },
385
+ {
386
+ "slug": "help/filtering/filter-api",
387
+ "path": "docs/help/filtering/filter-api.md",
388
+ "title": "Filter API",
389
+ "markdown": "# Filter API\n\nTwo surfaces — pick based on whether the caller is inside or outside the\ncomponent that owns the grid state.\n\n## `SvGridApi` (imperative)\n\nAvailable via `<SvGrid onApiReady={(api) => /* … */}>`.\n\n```ts\napi.setFilter(\n columnId: string,\n filter: { operator: SvGridFilterOperator; value?: string } | null,\n): void\n\napi.clearFilter(columnId: string): void\n```\n\n`SvGridFilterOperator` is the union: `'contains' | 'equals' | 'startsWith'\n| 'greaterThan' | 'lessThan' | 'isBlank'`.\n\nPassing `null` clears the filter on that column.\n\n```ts\napi.setFilter('status', { operator: 'equals', value: 'active' })\napi.setFilter('age', { operator: 'greaterThan', value: '30' })\napi.setFilter('email', { operator: 'contains', value: '@example.com' })\napi.setFilter('department', null) // clear\napi.clearFilter('age') // same effect, sugared\n```\n\nThe wrapper internally writes into the `columnFilters` state slice and\nre-runs the filtered row model.\n\n## Controlled state\n\nFor full control (history, persistence, server-side):\n\n```svelte\n<script lang=\"ts\">\n let columnFilters = $state<Array<{ id: string; value: unknown }>>([])\n</script>\n\n<SvGrid\n {data} {columns} features={features}\n state={{ columnFilters }}\n onColumnFiltersChange={(next) => (columnFilters = next)}\n/>\n```\n\nThe state is `Array<{ id: string; value: unknown; fn?: keyof typeof filterFns }>`.\nUse `fn` to pick a specific built-in filter function:\n\n```ts\ncolumnFilters = [\n { id: 'firstName', value: 'ada', fn: 'includesString' },\n { id: 'age', value: 30, fn: 'equals' },\n]\n```\n\n## Read the current filters\n\nFrom the imperative API there is no `getFilters()` getter today — read the\nunderlying state via the grid instance:\n\n```ts\nconst grid = api as unknown as { /* internals are not part of the public type */ }\n```\n\nThis is not a stable surface. If you need to read filters from outside,\ncontrol the state.\n\n## See also\n\n- [Filter conditions](./filter-conditions.md)\n- [Applying filters](./applying-filters.md)\n- [Missing features](../missing-features.md) — `api.getFilters()` would be nice.\n"
390
+ },
391
+ {
392
+ "slug": "help/filtering/filter-conditions",
393
+ "path": "docs/help/filtering/filter-conditions.md",
394
+ "title": "Filter conditions",
395
+ "markdown": "# Filter conditions\n\nA \"filter condition\" is a `(column, operator, value)` triple. The grid\nstores filter conditions in `state.columnFilters`.\n\n## Shape\n\n```ts\ntype ColumnFilter = {\n id: string // column id\n value: unknown // operator-specific value\n fn?: keyof typeof filterFns // optional explicit filter function\n}\ntype ColumnFiltersState = ColumnFilter[]\n```\n\nThrough the wrapper, the menu uses a richer per-column representation\n(operator + value) — the wrapper converts between the two when state\ncrosses the boundary.\n\n## AND vs. OR\n\nMultiple filter conditions for **different columns** AND together. Two\nfilters on the **same column** are not natively supported — the second\nwrite overwrites the first.\n\nTo express OR within a column (`status = active OR pending`), use the\n[set filter](./set-filter.md) pattern.\n\nTo express OR across columns (`firstName = ada OR lastName = lovelace`),\ndo it outside the grid by filtering the data array before passing it in.\n\n## Conditions through controlled state\n\n```svelte\n<script lang=\"ts\">\n let columnFilters = $state([\n { id: 'department', value: 'Engineering' },\n { id: 'age', value: 30, fn: 'equals' },\n ])\n</script>\n\n<SvGrid\n {data} {columns} features={features}\n state={{ columnFilters }}\n onColumnFiltersChange={(next) => (columnFilters = next)}\n/>\n```\n\n## Clearing\n\n```ts\napi.clearFilter('department') // single column\n// or, controlled-state:\ncolumnFilters = columnFilters.filter((c) => c.id !== 'department')\n```\n\nThere is no `clearAllFilters()` on the API today — iterate or replace the\nstate slice:\n\n```ts\ncolumnFilters = []\n```\n\n## See also\n\n- [Applying filters](./applying-filters.md)\n- [Filter API](./filter-api.md)\n"
396
+ },
397
+ {
398
+ "slug": "help/filtering/floating-filters",
399
+ "path": "docs/help/filtering/floating-filters.md",
400
+ "title": "Floating filters",
401
+ "markdown": "# Floating filters\n\n\"Floating filters\" are the always-visible filter inputs that sit\n**between** the header row and the body — same idea as a filter row, but\nmatched per-operator to the underlying column menu.\n\n## Status\n\nSvGrid has a **filter row** (`filterMode=\"row\"`) that gives you a single\ninput per text column. It does not yet have full \"floating filter\" parity\nwhere the inline input mirrors the column menu's operator — e.g. a number\ncolumn showing a `>` icon next to the filter input.\n\nThe filter row uses `contains` for text columns and `equals` for number /\ndate / checkbox columns.\n\n## Enable the filter row\n\n```svelte\n<SvGrid {data} {columns} features={features} filterMode=\"row\" />\n```\n\nIf you also want the column menu icon present, set both surfaces\nexplicitly:\n\n```svelte\n<SvGrid\n {data} {columns} features={features}\n showFilterRow={true}\n showColumnFilters={true}\n/>\n```\n\n## Per-operator floating filter\n\nToday this needs a custom header. Render your own input inside the column\nheader via `header: (ctx) => renderSnippet(...)` and write into\ncontrolled `columnFilters` state when the user types.\n\n```svelte\n{#snippet RangeHeader(p: { id: string })}\n <div class=\"flex flex-col gap-1\">\n <span>{p.id}</span>\n <input\n placeholder=\"≥ min\"\n oninput={(e) => applyMin(p.id, +e.currentTarget.value)}\n class=\"w-20 rounded border border-slate-300 px-1 py-0.5 text-xs\"\n />\n </div>\n{/snippet}\n```\n\n## Tracked at\n\n[Missing features](../missing-features.md) — first-class floating filters\nwith operator parity.\n\n## See also\n\n- [Overview](./overview.md)\n- [Custom header components](../columns/custom-header-components.md)\n"
402
+ },
403
+ {
404
+ "slug": "help/filtering/number-filter",
405
+ "path": "docs/help/filtering/number-filter.md",
406
+ "title": "Number filter",
407
+ "markdown": "# Number filter\n\nA column with `editorType: 'number'` gets the **number** filter operator\nset: `equals`, `greaterThan`, `lessThan`, `isBlank`. The default operator\nis `equals`.\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n { field: 'age', header: 'Age', editorType: 'number' },\n {\n field: 'salary', header: 'Salary', editorType: 'number',\n format: { type: 'currency', currency: 'USD' },\n },\n]\n```\n\n## Range — `between`\n\nThe headless `applyExcelFilter` supports `between`, but the built-in column\nmenu only exposes `equals`, `greaterThan`, `lessThan`, and `isBlank`. To\nfilter by a numeric range from the UI today, combine two filters in the\nheader menu (`greaterThan` + `lessThan`).\n\nProgrammatically, you can apply a `between` via the headless API:\n\n```ts\nimport { applyExcelFilter } from 'sv-grid-community'\n\napplyExcelFilter(72, { id: 'age', operator: 'between', value: 18, valueTo: 65 })\n// → true\n```\n\nThe `between` operator is **inclusive** at both ends.\n\n## Numeric input parsing\n\nThe filter value comes in from the DOM as a string. Use `parseEditorValue`\nto convert:\n\n```ts\nimport { parseEditorValue } from 'sv-grid-community'\n\nparseEditorValue('number', '4.5') // 4.5\nparseEditorValue('number', 'abc') // NaN — reject\n```\n\nThe grid does this for you when the user types into a header filter.\n\n## Locale\n\nNumber filters compare raw `Number(cellValue)` against `Number(filter.value)`.\nThey do not parse \"1,234.50\" or \"1 234,50\" — feed the column raw numbers,\nand use a `format` on the column for display.\n\n## See also\n\n- [Filter conditions](./filter-conditions.md)\n- [Number editor](../editing/provided-editors.md#number-editor)\n"
408
+ },
409
+ {
410
+ "slug": "help/filtering/overview",
411
+ "path": "docs/help/filtering/overview.md",
412
+ "title": "Filtering — overview",
413
+ "markdown": "# Filtering — overview\n\nSvGrid offers four filtering surfaces. You opt into the one(s) you need\nthrough the `filterMode` prop on `<SvGrid>`:\n\n| `filterMode` | What it shows |\n| ------------ | ------------- |\n| `'menu'` (default) | A \"filter icon\" in each header opens a per-column operator + value popover. |\n| `'row'` | A filter row under the header — one input per column. |\n| `'global'` | A single search box above the grid that searches all visible columns. |\n| `'none'` | No filter UI. Drive filters programmatically only. |\n\n```svelte\n<SvGrid {data} {columns} features={features} filterMode=\"row\" />\n```\n\nPer-surface props (`showColumnFilters`, `showFilterRow`, `showGlobalFilter`)\noverride `filterMode` when set explicitly — useful when you want two\nsurfaces simultaneously.\n\n## Feature registration\n\nFiltering is gated by `columnFilteringFeature` plus\n`createFilteredRowModel`. Both must be registered for the column filter UI\nto actually filter rows:\n\n```ts\nimport {\n tableFeatures, columnFilteringFeature, createFilteredRowModel,\n} from 'sv-grid-community'\n\nconst features = tableFeatures({ columnFilteringFeature })\n```\n\nThe wrapper auto-registers `createFilteredRowModel` when the feature is\npresent.\n\n## Operators\n\nAll built-in operators:\n\n| Operator | Applies to | Behaviour |\n| ------------- | ---------- | --------- |\n| `contains` | text | case-insensitive substring |\n| `equals` | text, num, date, bool | strict equality (numeric where possible) |\n| `startsWith` | text | case-insensitive prefix |\n| `greaterThan` | num, date | strict `>` |\n| `lessThan` | num, date | strict `<` |\n| `between` | num, date | inclusive range — requires `valueTo` |\n| `isBlank` | any | empty / null / undefined / whitespace |\n\nThe set of operators offered per column depends on `editorType`:\n\n| `editorType` | operators |\n| ------------ | --------- |\n| `'text'` (default) | contains, equals, startsWith, isBlank |\n| `'number'` | equals, greaterThan, lessThan, isBlank |\n| `'date'` / `'datetime'` | equals, lessThan, greaterThan, isBlank |\n| `'checkbox'` | equals, isBlank |\n\n## Built-in `filterFns`\n\nFor programmatic filtering (without the menu), pass a `filterFn` on the\ncolumn or use the headless `createFilteredRowModel` directly.\n\n```ts\nimport { filterFns } from 'sv-grid-community'\n\nfilterFns.includesString(cellValue, query)\nfilterFns.equals(cellValue, query)\n```\n\n## See also\n\n- [Text filter](./text-filter.md)\n- [Number filter](./number-filter.md)\n- [Date filter](./date-filter.md)\n- [Set filter](./set-filter.md)\n- [Filter API](./filter-api.md)\n- [demos/03-excel-filters.svelte](../../../examples/src/demos/03-excel-filters.svelte)\n"
414
+ },
415
+ {
416
+ "slug": "help/filtering/set-filter",
417
+ "path": "docs/help/filtering/set-filter.md",
418
+ "title": "Set filter",
419
+ "markdown": "# Set filter\n\nA \"set filter\" (a.k.a. value filter, list filter) shows a checklist of all\ndistinct values in a column and lets the user pick which to include. It's\nwhat you reach for to filter `status` to \"active OR pending\", or\n`department` to a few specific teams.\n\n## Status\n\nA first-pass **value-checklist** UI is present inside the column menu —\nthe menu's lower section enumerates the distinct values of the column\nbased on the current data, with a search box and a \"select all\".\n\nIt is not yet on parity with a full enterprise set-filter feature:\n\n- No tree-list (hierarchical values).\n- No async / server-side value source.\n- No Excel-mode (extending the set as data scrolls in).\n- No first-class API beyond the column menu.\n\nThese are tracked as enhancements on the\n[gap list](../missing-features.md).\n\n## Programmatic equivalent\n\nTo get set-filter semantics from outside the grid today, derive a \"pseudo\ncolumn\" with an aggregate filter using controlled state:\n\n```svelte\n<script lang=\"ts\">\n let allowed = $state(new Set(['active', 'pending']))\n let rows = $state<Person[]>(makePeople(500))\n\n const filtered = $derived(rows.filter((r) => allowed.has(r.status)))\n</script>\n\n<div>\n <label><input type=\"checkbox\" checked={allowed.has('active')}\n onchange={(e) => { e.currentTarget.checked ? allowed.add('active') : allowed.delete('active'); allowed = new Set(allowed) }} /> Active</label>\n <!-- … -->\n</div>\n\n<SvGrid data={filtered} {columns} features={features} />\n```\n\nThis bypasses the column menu but is the right shape until the built-in\nset filter grows the missing capabilities.\n\n## See also\n\n- [Filter conditions](./filter-conditions.md)\n- [Filter API](./filter-api.md)\n- [Missing features](../missing-features.md)\n"
420
+ },
421
+ {
422
+ "slug": "help/filtering/text-filter",
423
+ "path": "docs/help/filtering/text-filter.md",
424
+ "title": "Text filter",
425
+ "markdown": "# Text filter\n\nA column with no `editorType` (or `editorType: 'text'`) gets the **text**\nfilter operator set: `contains`, `equals`, `startsWith`, `isBlank`. The\ndefault operator is `contains`.\n\n## Through the column menu\n\nClick the filter icon in the header → pick an operator → type a value →\npress Enter. The grid filters as you type (with a 150ms debounce).\n\n## Through the filter row\n\n```svelte\n<SvGrid {data} {columns} features={features} filterMode=\"row\" />\n```\n\nEach text column shows a single input. The applied operator is `contains`.\n\n## Programmatically\n\n```ts\napi.setFilter('firstName', { operator: 'contains', value: 'ada' })\napi.clearFilter('firstName')\n```\n\n## Case sensitivity\n\nAll built-in text operators are **case-insensitive**.\n\n```ts\napplyExcelFilter('Ada Lovelace', { id: 'name', operator: 'contains', value: 'ADA' })\n// → true\n```\n\nIf you need case-sensitive comparisons, define your own column-level\nfilter through controlled state:\n\n```svelte\n<script lang=\"ts\">\n let columnFilters = $state<Array<{ id: string; value: unknown }>>([])\n</script>\n\n<SvGrid\n {data} {columns} features={features}\n state={{ columnFilters }}\n onColumnFiltersChange={(next) => (columnFilters = next)}\n/>\n```\n\n…and filter the data yourself before passing it in, or write a custom\n`createFilteredRowModel`.\n\n## Locale-aware text comparison\n\nThere is no built-in locale-aware text filter today. The grid's filter\noperators use `String.prototype.toLowerCase()` then `includes` / `startsWith`\n— they handle ASCII case folding but not Turkish-`i` or Unicode normalization.\n\nFor accent-insensitive search, normalize before comparing:\n\n```ts\nconst norm = (s: string) =>\n s.normalize('NFD').replace(/\\p{Diacritic}/gu, '').toLowerCase()\n\n// in your filterFn-equivalent code:\nnorm(cellValue).includes(norm(query))\n```\n\n## See also\n\n- [Filter conditions](./filter-conditions.md)\n- [Custom column filters](./custom-column-filters.md)\n- [excel-filters.ts](../../../packages/sv-grid-community/src/filtering/excel-filters.ts)\n"
426
+ },
427
+ {
428
+ "slug": "help/index",
429
+ "path": "docs/help/index.md",
430
+ "title": "SvGrid Help",
431
+ "markdown": "# SvGrid Help\n\nTopic-oriented documentation for SvGrid. Each page is a focused\nexplanation of one feature with copy-paste code that runs against the\nshipping library - written for SvGrid, not translated from another grid.\n\nStart with [Getting Started](../getting-started.md) if you have not\nalready.\n\n## Background\n\n- [Why headless?](../why-headless.md) - what the headless core gives you and when to reach for it\n- [Tailwind integration](./tailwind.md) - re-theming the grid via `--sg-*` tokens, dark-mode wiring, what *not* to do\n- [Data export and printing](./export.md) - Excel, PDF, CSV, TSV, HTML, and Print (ships in the paid `sv-grid-pro` add-on)\n- [Migrating from AG Grid](./migrating-from-ag-grid.md) - 30-minute recipe: column translation, feature mapping, API differences, gotchas\n\n## Core features\n\n### Columns\n\n- [Column definitions](./columns/column-definitions.md)\n- [Updating definitions](./columns/updating-definitions.md)\n- [Column state](./columns/column-state.md)\n- [Column headers — styling & height](./columns/column-headers.md)\n- [Column groups](./columns/column-groups.md)\n- [Column sizing](./columns/column-sizing.md)\n- [Column moving](./columns/column-moving.md)\n- [Column pinning](./columns/column-pinning.md)\n- [Column spanning](./columns/column-spanning.md)\n- [Custom header components](./columns/custom-header-components.md)\n\n### Rows\n\n- [Row data](./rows/row-data.md)\n- [Row sorting](./rows/row-sorting.md)\n- [Row spanning](./rows/row-spanning.md)\n- [Row pinning](./rows/row-pinning.md)\n- [Row height](./rows/row-height.md)\n- [Styling rows](./rows/styling-rows.md)\n- [Row pagination](./rows/row-pagination.md)\n- [Accessing rows](./rows/accessing-rows.md)\n- [Row dragging](./rows/row-dragging.md)\n- [Full-width rows](./rows/full-width-rows.md)\n\n### Cells\n\n- [Getting values](./cells/getting-values.md)\n- [Text formatting](./cells/text-formatting.md)\n- [Cell components](./cells/cell-components.md)\n- [Cell data types](./cells/cell-data-types.md)\n- [Styling cells](./cells/styling-cells.md)\n- [Highlighting changes](./cells/highlighting-changes.md)\n- [Tooltips](./cells/tooltips.md)\n- [Expressions](./cells/expressions.md)\n- [View refresh](./cells/view-refresh.md)\n- [Cell text selection](./cells/cell-text-selection.md)\n\n### Filtering\n\n- [Overview](./filtering/overview.md)\n- [Text filter](./filtering/text-filter.md)\n- [Number filter](./filtering/number-filter.md)\n- [Date filter](./filtering/date-filter.md)\n- [Set filter](./filtering/set-filter.md)\n- [Filter conditions](./filtering/filter-conditions.md)\n- [Applying filters](./filtering/applying-filters.md)\n- [Filter API](./filtering/filter-api.md)\n- [Custom column filters](./filtering/custom-column-filters.md)\n- [Floating filters](./filtering/floating-filters.md)\n\n### Editing\n\n- [Overview](./editing/overview.md)\n- [Start / stop editing](./editing/start-stop-editing.md)\n- [Parsing values](./editing/parsing-values.md)\n- [Saving values](./editing/saving-values.md)\n- [Edit components](./editing/edit-components.md)\n- [Provided cell editors](./editing/provided-editors.md)\n- [Undo / redo](./editing/undo-redo.md)\n- [Full-row editing](./editing/full-row.md)\n- [Validation](./editing/validation.md)\n\n## Conventions\n\nEach topic page is structured as:\n\n1. **What it is** — a one-sentence definition.\n2. **When to use it** — the situation that calls for this feature.\n3. **Minimal example** — copy-pasteable code that runs.\n4. **Reference** — the relevant exports and prop names.\n5. **Gotchas** — known limits, gaps, or things that surprise people.\n\nPages explicitly note when a feature is **not yet implemented** in the\ncommunity build so you know what you can rely on. The current\ngap list is at [missing-features.md](./missing-features.md).\n"
432
+ },
433
+ {
434
+ "slug": "help/migrating-from-ag-grid",
435
+ "path": "docs/help/migrating-from-ag-grid.md",
436
+ "title": "Migrating from AG Grid to SvGrid",
437
+ "markdown": "# Migrating from AG Grid to SvGrid\n\nIf you tried AG Grid on a Svelte 5 project — via `ag-grid-svelte`, the\nold `ag-grid-community/svelte`, or a hand-rolled wrapper — you probably\nhit the same friction everyone hits: the bridge between AG Grid's\nReact/Angular-first API and Svelte 5 runes is brittle, the bundle is\nheavy, and the Enterprise pricing only makes sense at scale.\n\nThis page is a 30-minute migration recipe from AG Grid to SvGrid. It\ncovers what maps 1:1, what's different by design, and what you'll lose.\nWe tell you when **not** to switch at the bottom.\n\n## TL;DR\n\n| | AG Grid Community | AG Grid Enterprise | SvGrid Community | sv-grid-pro |\n| --- | --- | --- | --- | --- |\n| **License** | MIT | Commercial (~$999/dev/yr) | **MIT** | $599/dev/yr (single app) or $999/dev/yr (multi app) |\n| **Svelte 5 native** | ❌ (wrapper) | ❌ (wrapper) | ✅ | ✅ |\n| **Bundle (gzipped)** | ~250 KB | ~400 KB | ~25 KB core, ~50 KB w/ all features | + ~12 KB for Pro |\n| **Sorting / filtering / grouping** | ✅ | ✅ | ✅ | (in Community) |\n| **Master/detail, tree, range select** | ❌ Enterprise only | ✅ | ✅ (free) | (in Community) |\n| **Excel export** | ❌ | ✅ Enterprise | ❌ | ✅ |\n| **PDF / CSV / TSV / HTML export** | ❌ | Partial | ❌ | ✅ |\n| **Print view** | ❌ | ❌ | ❌ | ✅ |\n| **Set filter / Excel-style filter menu** | ❌ Enterprise | ✅ | ✅ (free) | (in Community) |\n\nThe headline: **SvGrid Community gives you most of AG Grid Enterprise's\nfeatures for free**, and `sv-grid-pro` adds the export + print pack for\n~40% less than AG Grid Enterprise. The catch is Svelte-only and a much\nsmaller ecosystem.\n\n## Mental model — what changes\n\nAG Grid is one big object you configure declaratively. SvGrid is a\n**headless engine** (`createSvGrid`) with an optional **render\ncomponent** (`<SvGrid>`) on top — the same split TanStack Table made\npopular. You can use either layer; most projects use the render\ncomponent.\n\n```svelte\n<!-- AG Grid (via a Svelte wrapper) -->\n<AgGridSvelte\n gridOptions={{\n rowData: rows,\n columnDefs: columns,\n onGridReady: (params) => (gridApi = params.api),\n }}\n/>\n\n<!-- SvGrid -->\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onApiReady={(api) => (gridApi = api)}\n/>\n```\n\nThree things to note:\n\n1. **No giant `gridOptions` blob.** Each capability is a top-level prop.\n2. **Features are opt-in.** You pass a `features` object built with\n `tableFeatures({...})` — only the features you list ship JS.\n3. **`onApiReady`** gives you a typed `SvGridApi` that is roughly the\n AG Grid `gridApi` equivalent (see the API-mapping table below).\n\n## Column definitions — direct translation\n\nThe shapes are similar enough that you can usually translate by hand\nwithout thinking too hard.\n\n```ts\n// AG Grid\nconst columnDefs: ColDef[] = [\n { field: 'name', headerName: 'Name', sortable: true, filter: true, width: 200 },\n { field: 'price', headerName: 'Price', type: 'numericColumn',\n valueFormatter: ({ value }) => `$${value.toFixed(2)}` },\n { field: 'date', headerName: 'Date',\n valueGetter: ({ data }) => new Date(data.date).toISOString().slice(0, 10) },\n { field: 'status', headerName: 'Status',\n cellRenderer: StatusCellRenderer,\n cellRendererParams: { onChange: handleStatusChange } },\n]\n```\n\n```ts\n// SvGrid\nimport { renderComponent, type ColumnDef } from 'sv-grid-community'\nimport StatusCell from './StatusCell.svelte'\n\nconst columns: ColumnDef<typeof features, Row>[] = [\n { field: 'name', header: 'Name', width: 200 }, // sortable + filterable by default\n { field: 'price', header: 'Price',\n format: { type: 'currency', currency: 'USD' } },\n { field: 'date', header: 'Date',\n format: { type: 'date', pattern: 'y-m-d' } },\n { field: 'status', header: 'Status',\n cell: renderComponent(StatusCell, (ctx) => ({\n value: ctx.getValue(),\n onChange: handleStatusChange,\n })),\n },\n]\n```\n\n### Property mapping\n\n| AG Grid | SvGrid | Notes |\n| --- | --- | --- |\n| `field` | `field` | Same. |\n| `headerName` | `header` | Accepts a string or a snippet/component. |\n| `width` | `width` | Same. |\n| `minWidth` / `maxWidth` | `minWidth` / `maxWidth` | Same. |\n| `sortable: true` | (default) | Sorting is on when `rowSortingFeature` is registered. |\n| `filter: true` | (default) | Filtering is on when `columnFilteringFeature` is registered. |\n| `valueFormatter` | `format: { ... }` | Built-in types: `number`, `currency`, `percent`, `date`. For custom, use `cell`. |\n| `valueGetter` | `accessorFn` | Returns the value for sorting/filtering. |\n| `cellRenderer` + `cellRendererParams` | `cell: renderComponent(C, ctx => props)` | One call, type-checked. |\n| `cellEditor: 'agTextCellEditor'` | `editorType: 'text'` | Built-in: `text`, `number`, `checkbox`, `date`. |\n| `editable: true` | `enableInlineEditing` prop on `<SvGrid>` | Per-grid, not per-column. (Per-column control on the roadmap.) |\n| `pinned: 'left'` / `'right'` | Right-click column menu → Pin | Set programmatically via the api. |\n| `rowGroup: true` | Via `setGroupBy([colId])` | See Grouping below. |\n| `aggFunc: 'sum'` | `aggregation: 'sum'` | Built-in: `sum`, `avg`, `min`, `max`, `count`. |\n\n## Feature registration — the one new thing\n\nAG Grid auto-enables most features; you turn them off. SvGrid is the\nopposite — features are opt-in. The result is a smaller bundle.\n\n```ts\nimport {\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n rowPaginationFeature,\n rowSelectionFeature,\n} from 'sv-grid-community'\n\nconst features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n // omit any you don't need — their code won't ship\n})\n```\n\nPass `features` to `<SvGrid>` once. From then on the grid behaves like\nAG Grid's `enableSorting`, `enableFilter`, `rowSelection`, etc. are all\non for the registered features.\n\n## API mapping (`gridApi` → `SvGridApi`)\n\nYou get the SvGrid API from `onApiReady` (equivalent to AG Grid's\n`onGridReady`).\n\n| AG Grid `gridApi.X()` | SvGrid `api.X()` |\n| --- | --- |\n| `setRowData(rows)` | (declarative — just update `data` prop) |\n| `addRow(row)` / `applyTransaction({ add: [row] })` | `api.addRow(row)` / `api.addRows(rows)` |\n| `applyTransaction({ remove: [row] })` | `api.removeRow(rowIndex)` |\n| `getValue(colId, rowNode)` | `api.getCellValue(rowIndex, columnId)` |\n| `setValue(...)` | `api.setCellValue(rowIndex, columnId, value)` |\n| `setColumnVisible(colId, visible)` | `api.setColumnVisible(columnId, visible)` |\n| `getSortModel()` / `setSortModel()` | `api.setSort(columnId, 'asc'\\|'desc'\\|null)` |\n| `setFilterModel({...})` | `api.setFilter(columnId, { operator, value })` |\n| `getDisplayedRowAtIndex(i)` / `forEachNodeAfterFilterAndSort(...)` | `api.getDisplayedRows()` |\n| `getModel()` (raw rows) | `api.getData()` |\n\n## Common patterns\n\n### Sorting + filtering + pagination (the 80% case)\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n } from 'sv-grid-community'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowPaginationFeature,\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination\n showColumnFilters\n/>\n```\n\nAG Grid equivalent: `gridOptions: { defaultColDef: { sortable: true, filter: true }, pagination: true }`.\n\n### Cell editing with persistence\n\n```svelte\n<script lang=\"ts\">\n function onCellValueChange(e: { rowIndex: number; columnId: string; value: unknown }) {\n // Persist however you like (fetch to backend, optimistic local update, etc.)\n console.log('cell changed', e)\n }\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n enableInlineEditing\n onCellValueChange={onCellValueChange}\n/>\n```\n\nAG Grid equivalent: `onCellValueChanged: ({ data, colDef, newValue, oldValue }) => ...`.\nSvGrid's event payload is column-id + row-index based rather than node-based; the row data is yours\nto mutate (or not) on the `rows` array you passed in.\n\n### Grouping + aggregation\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n columnGroupingFeature,\n rowSortingFeature,\n rowExpandingFeature,\n } from 'sv-grid-community'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n const columns = [\n { field: 'department', header: 'Department' },\n { field: 'team', header: 'Team' },\n { field: 'salary', header: 'Salary', aggregation: 'sum',\n format: { type: 'currency', currency: 'USD' } },\n ]\n\n function setGroup(api) {\n api.setGroupBy(['department', 'team'])\n }\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showGroupingControls\n onApiReady={setGroup}\n/>\n```\n\nAG Grid Enterprise's `rowGroupPanelShow: 'always'` + `aggFunc: 'sum'` translates 1:1.\n\n### Master / detail\n\nAG Grid Enterprise feature; **free in SvGrid Community**. See\n[demo 08](https://sv-grid.dev/#/demos/08-tree-and-master-detail) for the exact pattern.\n\n### Server-side data\n\nAG Grid uses an `IServerSideDatasource` interface. SvGrid uses\n`externalSort` + `externalFilter` props — your code keeps full control\nover the query, and the grid records UI state but doesn't re-order rows\nlocally. See [demo 09](https://sv-grid.dev/#/demos/09-server-side).\n\n### Excel / PDF export\n\nAG Grid: `gridApi.exportDataAsExcel({...})` (Enterprise-only).\nSvGrid: install `sv-grid-pro`, call `api.exportData({ format: 'xlsx', ... })`. See [Data export and printing](./export.md).\n\n```ts\nimport { installPro, setLicenseKey } from 'sv-grid-pro'\nsetLicenseKey('SVPRO-...') // your Pro key\n\n// inside onApiReady:\nconst pro = installPro(api)\nawait pro.exportData({ format: 'xlsx', filename: 'orders' })\n```\n\n## Gotchas — things that don't translate directly\n\n### 1. Per-column `editable: true`\nSvGrid v1.0 toggles editing at the grid level (`enableInlineEditing`).\nPer-column editability is on the roadmap — until then, you can gate\nedits in your `onCellValueChange` handler.\n\n### 2. Column drag-to-reorder\nSvGrid v1.0 supports column reorder via the API (`setColumnOrder`), not\nheader drag. Built-in header drag is on the roadmap. Most teams don't\nmiss it — it's a power-user feature.\n\n### 3. AG Grid `valueGetter` chains\nAG Grid's `valueGetter` can read other column values via the API. In\nSvGrid, `accessorFn` only receives the row; if you need cross-column\ncomputed values, do it in the cell renderer with `ctx.row.original` or\ncompute the derived value upstream and store it in the row.\n\n### 4. `cellClass` / `rowClass` callbacks\nOn the roadmap. For now, render a wrapper element in your `cell` snippet\nwith the conditional class.\n\n### 5. The Status Bar / Side Bar / Tool Panels\nAG Grid's chrome (status bar with row count, side bar with filters and\ncolumns panels) doesn't exist in SvGrid — build it as plain Svelte\nmarkup around the grid. Most teams build their own anyway because\nAG Grid's defaults rarely match a polished design system.\n\n### 6. Set filter (the Excel-style funnel popup)\nSvGrid ships an Excel-style filter menu (free in Community). API surface\nis similar but not identical — see [Set filter](./filtering/set-filter.md).\n\n## When NOT to migrate\n\nBe honest. Stay on AG Grid if you:\n\n- **Use multiple frameworks** — AG Grid has React, Angular, Vue, Solid, Qwik, vanilla adapters. SvGrid is Svelte-only.\n- **Use AG Grid's integrated charts** — those depend on AG Grid's chart engine; SvGrid has no equivalent.\n- **Depend on AG Grid pivoting** — not in SvGrid's roadmap.\n- **Are mid-project and shipping in <2 weeks** — the migration is a few hours per grid, but only do it when you have buffer.\n- **Have a Svelte 4 codebase you can't upgrade** — SvGrid requires Svelte 5 runes. (Consider [htmlelements.com](https://www.htmlelements.com) for vanilla / multi-framework.)\n\nIf none of those apply: switching saves you $400-$1000 per dev per year,\ncuts your bundle by 200+ KB, and gives you a Svelte-native API that\nplays well with runes.\n\n## Step-by-step migration\n\nA typical migration of a single grid takes 1-3 hours:\n\n1. **Install** — `pnpm add sv-grid-community` (and `sv-grid-pro` if you need export).\n2. **Translate columnDefs** — use the mapping table above. Most columns are 1:1.\n3. **Wrap features** — figure out which AG Grid features you actually use; register only those in `tableFeatures({...})`.\n4. **Swap the component** — `<AgGridSvelte gridOptions={...}>` → `<SvGrid data={rows} columns={columns} features={features}>`.\n5. **Move event handlers** — AG Grid `onCellValueChanged` → SvGrid `onCellValueChange` (signature differs slightly, see above).\n6. **Move API calls** — AG Grid `gridApi.X()` → SvGrid `api.X()` per the API table.\n7. **Test interactions** — sort, filter, edit, select. Most \"just works.\"\n8. **Remove `ag-grid-*` packages** — `pnpm remove ag-grid-community ag-grid-svelte` etc. Inspect your bundle to confirm the 200+ KB drop.\n\n## Need help migrating?\n\nPro customers get **migration help included** with the support plan\n(architecture review, port one grid for you as a reference). Email\n`support@jqwidgets.com` after purchase, or `sales@jqwidgets.com` for\npre-sales questions.\n\n## See also\n\n- [Getting started](../getting-started.md) — full SvGrid walkthrough\n- [Why headless?](../why-headless.md) — the headless / render-component split\n- [Data export and printing](./export.md) — the `sv-grid-pro` feature pack\n- [SvGrid vs AG Grid comparison page](https://sv-grid.dev/#/compare/ag-grid)\n"
438
+ },
439
+ {
440
+ "slug": "help/missing-features",
441
+ "path": "docs/help/missing-features.md",
442
+ "title": "Missing features",
443
+ "markdown": "# Missing features\n\nThis is the honest accounting of capabilities the help topics flagged as\n**not yet implemented** in the community build. Each entry has:\n\n- a short description of the gap,\n- the topic page(s) that link here, and\n- a rough effort estimate (S - small, M - medium, L - large).\n\nUse this list to decide which to PR yourself, which to file an issue\nabout, and which to work around in the meantime.\n\n## Columns\n\n| Gap | Where | Effort |\n| --- | ----- | ------ |\n| `getRowId` prop on `<SvGrid>` wrapper (headless core supports identity) | [Row data](./rows/row-data.md) | S |\n| `cellClass(ctx)` / `rowClass(ctx)` callbacks for conditional styling | [Styling rows](./rows/styling-rows.md), [Styling cells](./cells/styling-cells.md) | S |\n| `getColumnWidths()` / `setColumnWidth()` on `SvGridApi` for persistence | [Column sizing](./columns/column-sizing.md) | S |\n| `setColumnPinning()` on `SvGridApi` (currently only via column menu) | [Column pinning](./columns/column-pinning.md) | S |\n| Header drag-to-reorder, built-in | [Column moving](./columns/column-moving.md) | M |\n| Per-column `enableSorting: false` / `enableFilter: false` flags | [Row sorting](./rows/row-sorting.md), [Filter API](./filtering/filter-api.md) | S |\n| Column spanning (`colSpan` on cell context) | [Column spanning](./columns/column-spanning.md) | L |\n\n## Rows\n\n| Gap | Where | Effort |\n| --- | ----- | ------ |\n| Row pinning (top / bottom) | [Row pinning](./rows/row-pinning.md) | M |\n| Row spanning (merged cells across rows) | [Row spanning](./rows/row-spanning.md) | L |\n| Full-width / detail row API | [Full-width rows](./rows/full-width-rows.md) | M |\n| Built-in row dragging - managed + unmanaged + drop zones + grid-to-grid | [Row dragging](./rows/row-dragging.md) | L |\n| ~~`api.getDisplayedRows()` (post-pipeline)~~ — **shipped in v1.0** | [Accessing rows](./rows/accessing-rows.md) | ✓ |\n| Variable row height with `<SvGrid>` (only available via the headless virtualizer today) | [Row height](./rows/row-height.md) | M |\n\n## Cells\n\n| Gap | Where | Effort |\n| --- | ----- | ------ |\n| Built-in tooltip API on `ColumnDef` | [Tooltips](./cells/tooltips.md) | S |\n| Built-in cell flash / animated change highlight on `ColumnDef` (demos roll their own via `renderSnippet`, e.g. `11-stock-market`, `18-cascade-editing`) | [Highlighting changes](./cells/highlighting-changes.md) | S |\n| Formula language / formula editor (enterprise-grade parity) | [Expressions](./cells/expressions.md) | L |\n| Find-in-grid feature | (enterprise gap) | M |\n| Notes feature | (enterprise gap) | M |\n\n## Export / Print\n\n| Gap | Where | Effort |\n| --- | ----- | ------ |\n| ~~Excel / xlsx export~~ — **shipped in `sv-grid-pro` v1.0** | [Export](./export.md) | ✓ |\n| ~~PDF export~~ — **shipped in `sv-grid-pro` v1.0** | [Export](./export.md) | ✓ |\n| ~~CSV / TSV / HTML export~~ — **shipped in `sv-grid-pro` v1.0** | [Export](./export.md) | ✓ |\n| ~~Print (printable view + browser print dialog)~~ — **shipped in `sv-grid-pro` v1.0** | [Export](./export.md) | ✓ |\n\n## Filtering\n\n| Gap | Where | Effort |\n| --- | ----- | ------ |\n| Floating filters with per-operator parity (inline filter row exists; per-operator UI under the funnel) | [Floating filters](./filtering/floating-filters.md) | M |\n| `between` operator exposed in the column menu | [Number filter](./filtering/number-filter.md), [Date filter](./filtering/date-filter.md) | S |\n| Set filter - tree-list, async values, Excel-mode | [Set filter](./filtering/set-filter.md) | L |\n| `multi`-filter on a single column (AND / OR within column) | [Filter conditions](./filtering/filter-conditions.md) | M |\n| ~~`clearAllFilters()` on `SvGridApi`~~ — **shipped in v1.0** | [Filter API](./filtering/filter-api.md) | ✓ |\n| ~~`api.getFilters()` reader on `SvGridApi`~~ — **shipped in v1.0** | [Filter API](./filtering/filter-api.md) | ✓ |\n| Locale-aware text filtering (accent-insensitive, ICU-style collation) | [Text filter](./filtering/text-filter.md) | M |\n\n## Editing\n\n| Gap | Where | Effort |\n| --- | ----- | ------ |\n| `cellEditor` slot for custom inline editors | [Edit components](./editing/edit-components.md) | M |\n| Built-in select & rich-select editors | [Provided editors](./editing/provided-editors.md) | M |\n| Built-in large-text (textarea) editor | [Provided editors](./editing/provided-editors.md) | S |\n| Per-column `valueParser` | [Parsing values](./editing/parsing-values.md) | S |\n| Per-column `validate()` returning `string | true` | [Validation](./editing/validation.md) | S |\n| Programmatic `api.startEditing(rowIndex, columnId)` / `stopEditing()` | [Start / stop editing](./editing/start-stop-editing.md) | S |\n| Full-row editing mode | [Full-row](./editing/full-row.md) | M |\n| Built-in undo / redo stack (now feasible since `onCellValueChange` ships) | [Undo / redo](./editing/undo-redo.md) | M |\n| Batch / staged editing mode (commit a set, not individual cells) | (enterprise gap) | M |\n\n## What's already there\n\nFor balance - the things that **are** built in and stable:\n\n- Sorting (single + multi, click + shift-click) with `onSortingChange` callback\n- Per-column filtering (menu + filter row + global), operator + value, with `onFiltersChange` callback\n- **External-data mode**: `externalSort` / `externalFilter` props let the consumer own row ordering and filtering for server-side / tree data; the grid records UI state but does not re-order rows (see demo `09-server-side`, `08-tree-and-master-detail`)\n- Pagination, programmatic page controls\n- Grouping (one or more columns) + grouped/aggregated footer summaries\n- Row expansion (`rowExpandingFeature`)\n- Row selection (single, multi, checkbox column) with `onRowSelectionChange` callback\n- Cell range selection + copy/paste as TSV\n- Inline editing with five built-in editor types and `onCellValueChange(event)` callback (used by demo `18-cascade-editing`)\n- Row + column virtualization, with overscan controls; column virtualizer detects per-column size changes (so resize / fit-to-width re-render correctly)\n- Column resize via the header handle\n- Fit columns to viewport (`fitColumns` prop) - residue-absorbing, with modest shrink-to-fit\n- Column pinning (left / right, via menu)\n- Optional leading row-number column (`showRowNumbers`) and selection checkbox column\n- \"Source\" button in the gallery shell shows each demo's raw `.svelte` source for copy-paste\n- Imperative API for data + columns + filters + sort + grouping + visibility (`onApiReady`)\n- WAI-ARIA grid pattern with helpers in [`a11y.ts`](../../packages/sv-grid-community/src/a11y.ts) (see demo `17-accessibility`)\n- Locale-aware number / currency / percent / date / datetime formatters with `Intl` caching (see demo `15-localization`)\n- Built-in CSS custom-property theming surface (`--sg-*`); per-instance theme via `style=\"--sg-bg: ...\"` (see demo `10-custom-cells-and-themes`)\n- CSP-clean runtime: no `eval`, no `new Function`, no inline scripts (see demo `16-csp-compliant`)\n- SSR-friendly: the grid renders meaningful HTML before hydration (see demo `19-ssr`)\n\n## How to contribute\n\n1. Pick an entry from above.\n2. Open an issue describing the API you'd want - names, types, the\n minimal change.\n3. If you can write the patch, do so. Keep tests with the change.\n\nPRs that close items here are the fastest way to move SvGrid towards\nreal enterprise-quality parity in the community build.\n"
444
+ },
445
+ {
446
+ "slug": "help/rows/accessing-rows",
447
+ "path": "docs/help/rows/accessing-rows.md",
448
+ "title": "Accessing rows",
449
+ "markdown": "# Accessing rows\n\nYou can read the grid's current rows in three ways.\n\n## 1. From the imperative API\n\n`api.getData()` returns the **underlying data array** — pre-sort, pre-filter,\npre-paginate. Use this when you want to know what the grid is showing\n*before* its row model has been applied.\n\n```svelte\n<script lang=\"ts\">\n let api: SvGridApi<{}, Person> | null = $state(null)\n</script>\n\n<SvGrid {data} {columns} features={{}} onApiReady={(next) => (api = next)} />\n\n<button onclick={() => console.log(api?.getData().length)}>\n How many?\n</button>\n```\n\nThe reverse direction — write a cell — is also on `SvGridApi`:\n\n```ts\napi.setCellValue(rowIndex, columnId, value)\nconst v = api.getCellValue(rowIndex, columnId)\n```\n\n## 2. From the row model (post-pipeline)\n\nFor the rows the grid is **rendering** (after sort + filter + grouping +\npagination), you need the headless grid instance. The wrapper does not\nexpose it as a prop today; if you need post-pipeline access, instantiate\nthe headless engine yourself with `createSvGrid` and pass its computed\noutput into your own renderer. See\n[`packages/sv-grid-community/src/createGrid.svelte.ts`](../../../packages/sv-grid-community/src/createGrid.svelte.ts).\n\nA dedicated `api.getDisplayedRows()` is on the\n[gap list](../missing-features.md).\n\n## 3. From the source data directly\n\nIn most apps the cleanest path is \"the parent owns the data array, the\ngrid reflects it\". When you need to know what's in the grid, look at your\nown state — not the grid.\n\n```svelte\n<script lang=\"ts\">\n let rows = $state<Person[]>([])\n</script>\n\n<SvGrid data={rows} {columns} features={{}} />\n\n<p>{rows.length} rows total.</p>\n```\n\nThis works because data passed to `<SvGrid>` is treated as the authoritative\nsource — the grid never silently mutates it.\n\n## Iterating\n\n```ts\nconst data = api?.getData() ?? []\nfor (const row of data) {\n // ...\n}\n```\n\nFor thousands of rows in a hot loop, prefer indexed iteration:\n\n```ts\nconst data = api!.getData()\nfor (let i = 0; i < data.length; i++) {\n const row = data[i]!\n // ...\n}\n```\n\n## See also\n\n- [Row data](./row-data.md)\n- [Filter API](../filtering/filter-api.md)\n"
450
+ },
451
+ {
452
+ "slug": "help/rows/full-width-rows",
453
+ "path": "docs/help/rows/full-width-rows.md",
454
+ "title": "Full-width rows",
455
+ "markdown": "# Full-width rows\n\n\"Full-width rows\" are rows whose content takes the entire grid width\ninstead of being cell-by-cell — useful for inline editors, banner ads,\nsection dividers, and detail-row expansions.\n\n## Status\n\nBuilt-in support — through `enableRowSummaries={true}` for a single\nsticky footer row — exists; arbitrary mid-table full-width rows are\n**not** built in.\n\nFor inline master/detail (where expanding a row reveals a child) see\n[demos/08-tree-and-master-detail.svelte](../../../examples/src/demos/08-tree-and-master-detail.svelte):\nthe demo expands a row to mount a second `<SvGrid>` underneath, with the\ndetail keyed to the parent.\n\n## Workaround — render a divider between groups\n\nWhen you only need a visual band (no editing, no nested grid), render a\nplain DOM band above the grid for sticky banners, or use\n[grouping](./row-data.md) to get a \"group label\" row that visually spans\nthe row width through the indented label column.\n\n## Tracked at\n\n[Missing features](../missing-features.md) — first-class full-width rows\nand detail-row API.\n\n## See also\n\n- [Master / detail demo](../../../examples/src/demos/08-tree-and-master-detail.svelte)\n- [Grouping](./row-data.md)\n"
456
+ },
457
+ {
458
+ "slug": "help/rows/row-data",
459
+ "path": "docs/help/rows/row-data.md",
460
+ "title": "Row data",
461
+ "markdown": "# Row data\n\nRow data is whatever you pass to `<SvGrid data={...}>`. It is a\n`ReadonlyArray<TData>` where `TData` is your row type. Any shape works;\nthe grid does not require a base class or interface.\n\n## Static\n\n```svelte\n<script lang=\"ts\">\n const rows = [\n { id: '1', name: 'Ada', age: 36 },\n { id: '2', name: 'Linus', age: 54 },\n ]\n</script>\n<SvGrid data={rows} {columns} features={{}} />\n```\n\n## Reactive\n\nUse a Svelte 5 `$state` array. The grid re-derives its row model whenever\nthe array reference changes:\n\n```svelte\n<script lang=\"ts\">\n let rows = $state<Person[]>([])\n $effect(() => { fetchPeople().then((next) => (rows = next)) })\n</script>\n<SvGrid data={rows} {columns} features={{}} />\n```\n\nIn-place mutation works too — `rows.push(x)` or `rows[i] = y` — because\n`$state` arrays are deep-reactive.\n\n## Row identity (`getRowId`)\n\nWithout `getRowId`, the grid uses the row's array index as its id. That is\nfine for static data, but selection / expansion / edit state will not\nsurvive sorts, filters, or insertions.\n\nFor anything beyond a read-only grid, pass `getRowId`:\n\n```svelte\n<SvGrid\n data={rows}\n {columns}\n features={features}\n getRowId={(row) => row.id}\n/>\n```\n\n> Note: at the time of writing, the `<SvGrid>` wrapper does **not** expose a\n> top-level `getRowId` prop — the headless `createSvGrid` core supports stable\n> ids via the row's identity in the data array. Track this on the\n> [gap list](../missing-features.md).\n\n## Empty state\n\nThe grid renders the `emptyMessage` prop when `data.length === 0`:\n\n```svelte\n<SvGrid data={[]} {columns} features={{}} emptyMessage=\"No people found.\" />\n```\n\n## Loading state\n\nPass `loading` to overlay a spinner / skeleton (the wrapper has a built-in\noverlay layer):\n\n```svelte\n<SvGrid {data} {columns} features={{}} loading={isFetching} />\n```\n\nFor controlled skeleton-row UX in virtualized server-side grids, see\n[demos/09-server-side.svelte](../../../examples/src/demos/09-server-side.svelte).\n\n## See also\n\n- [Accessing rows](./accessing-rows.md)\n- [Row pagination](./row-pagination.md)\n- [Server-side guide](../../getting-started.md#11-server-side-data)\n"
462
+ },
463
+ {
464
+ "slug": "help/rows/row-dragging",
465
+ "path": "docs/help/rows/row-dragging.md",
466
+ "title": "Row dragging",
467
+ "markdown": "# Row dragging\n\nDrag-to-reorder for rows is a common interaction (kanban-like reordering,\nmoving items between two grids, dragging rows out to an external drop\nzone).\n\n## Status\n\nRow dragging is **not yet built in**. There is no managed-drag prop and no\nunmanaged-drag hook. There is no row drop zone support, and no\ngrid-to-grid drag.\n\n## Workaround — HTML5 DnD on a leading \"handle\" column\n\nAdd a leading column whose cell renders a draggable handle. Use a row id\nin the dataTransfer payload and reorder the data array on drop. A working\nsketch — drop into your column array:\n\n```ts\nimport { renderSnippet } from 'sv-grid-community'\n\n{#snippet Handle(p: { id: string })}\n <span\n draggable=\"true\"\n ondragstart={(e) => e.dataTransfer?.setData('text/plain', p.id)}\n aria-label=\"Drag row\"\n >⋮⋮</span>\n{/snippet}\n\nconst columns = [\n {\n id: 'drag',\n header: '',\n width: 24,\n cell: (ctx) => renderSnippet(Handle, { id: ctx.row.original.id }),\n },\n // ... rest of your columns\n]\n```\n\nThe receiving row needs `ondragover` / `ondrop` handlers — register them\nvia a custom `cell` renderer on a column the user can drop onto, or via a\nrow-level event listener on the grid container (only the leftmost cell of\neach row is enough to act as the drop target if the grid stretches to the\nviewport width).\n\n## Tracked at\n\n[Missing features](../missing-features.md) — first-class row dragging,\nexternal drop zones, grid-to-grid moves.\n\n## See also\n\n- [Custom cells](../cells/cell-components.md)\n- [Updating data](./row-data.md)\n"
468
+ },
469
+ {
470
+ "slug": "help/rows/row-height",
471
+ "path": "docs/help/rows/row-height.md",
472
+ "title": "Row height",
473
+ "markdown": "# Row height\n\nRow height is a single integer in pixels.\n\n```svelte\n<SvGrid {data} {columns} features={{}} rowHeight={36} />\n```\n\nThe default is 36 px. The virtualizer reads `rowHeight` once and uses it to\ncompute the visible window and total scroll height.\n\n## Density\n\nFor a density toggle, change `rowHeight` and a matching CSS custom property:\n\n```svelte\n<script lang=\"ts\">\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\n const px = $derived(density === 'compact' ? 28 : density === 'comfortable' ? 48 : 36)\n</script>\n\n<div style:--sg-row-height=\"{px}px\">\n <SvGrid {data} {columns} features={{}} rowHeight={px} />\n</div>\n```\n\nThe example gallery's\n[demos/10-custom-cells-and-themes.svelte](../../../examples/src/demos/10-custom-cells-and-themes.svelte)\nshows the density toggle in full.\n\n## Variable row height\n\nThe built-in virtualizer assumes uniform row height. **Variable height is not\nsupported by `<SvGrid>` directly**.\n\nIf you absolutely need it, use the lower-level headless virtualizer and roll\nyour own row layout:\n\n```ts\nimport { createSvelteVirtualizer } from 'sv-grid-community'\n\nconst virtualizer = createSvelteVirtualizer({\n count: () => rows.length,\n getScrollElement: () => scrollEl,\n estimateSize: (index) => rows[index]!.tall ? 80 : 36,\n overscan: 6,\n})\n```\n\nSee [`packages/sv-grid-community/src/virtualization/`](../../../packages/sv-grid-community/src/virtualization/).\n\n## Header height\n\nHeader height is independent of row height. See\n[Column headers](../columns/column-headers.md) for how to size it.\n\n## See also\n\n- [Row pinning](./row-pinning.md)\n- [Styling rows](./styling-rows.md)\n"
474
+ },
475
+ {
476
+ "slug": "help/rows/row-pagination",
477
+ "path": "docs/help/rows/row-pagination.md",
478
+ "title": "Row pagination",
479
+ "markdown": "# Row pagination\n\nPagination is opt-in. Register the feature, register the row model, then\neither turn it on with `showPagination` or drive its state from outside.\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures,\n rowPaginationFeature, createPaginatedRowModel,\n type ColumnDef,\n } from 'sv-grid-community'\n\n const features = tableFeatures({ rowPaginationFeature })\n</script>\n\n<SvGrid {data} {columns} features={features} showPagination={true} />\n```\n\nThe wrapper renders a footer with page nav and a page-size selector.\n\n## Controlled\n\n```svelte\n<script lang=\"ts\">\n let pagination = $state({ pageIndex: 0, pageSize: 25 })\n</script>\n\n<SvGrid\n {data} {columns} features={features}\n state={{ pagination }}\n onPaginationChange={(next) => (pagination = next)}\n showPagination={true}\n/>\n```\n\nUse the controlled path to:\n\n- persist the current page in the URL / `localStorage`\n- jump to a page programmatically: `pagination.pageIndex = 12`\n- drive a server-side fetcher (see [Server-side](../../getting-started.md#11-server-side-data))\n\n## Page size\n\nThe default is `data.length` (effectively \"all on one page\") until a\ncontrolled `pageSize` is supplied. The wrapper's footer picks 10 / 25 /\n50 / 100 by default. To customise, hide the built-in footer and render\nyour own using the `pagination` state and `setPagination(...)`.\n\n## Pagination + virtualization\n\nThese two **work together** but they solve different problems. Use\nvirtualization when the page itself is large (>200 rows); use pagination\nwhen you want explicit pages, when the user prints/exports, or when\nserver-side fetch returns pages.\n\nFor 100k-row + virtualized examples, pagination is off and the whole\nfiltered set is scrollable — see\n[demos/06-large-dataset.svelte](../../../examples/src/demos/06-large-dataset.svelte).\n\n## See also\n\n- [Server-side guide](../../getting-started.md#11-server-side-data)\n- [Accessing rows](./accessing-rows.md)\n"
480
+ },
481
+ {
482
+ "slug": "help/rows/row-pinning",
483
+ "path": "docs/help/rows/row-pinning.md",
484
+ "title": "Row pinning",
485
+ "markdown": "# Row pinning\n\nPinning a row sticks it to the **top** or **bottom** of the viewport so it\nnever scrolls out of view — useful for grand-total rows, \"you\" highlight\nrows in leaderboards, and pinned drafts.\n\n## Status\n\nRow pinning is **not yet built in**. The grid does not have a\n`pinnedTopRows` / `pinnedBottomRows` prop or a `setRowPinning` API method.\n\n## Workaround — header & footer rendering\n\nFor pinned-top: render a separate row above the grid using your own table\nmarkup that matches the column widths. For pinned-bottom, the grid's\n`enableRowSummaries` flag adds a footer row with aggregations — see\n[demos/07-grouping-aggregation.svelte](../../../examples/src/demos/07-grouping-aggregation.svelte).\n\n```svelte\n<div role=\"presentation\">\n <!-- mirror of header dimensions, rendered as a single sticky row -->\n <div class=\"sticky top-0 z-10 bg-yellow-100 px-2 py-1\">\n Pinned banner — promotions end Friday.\n </div>\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\nThis is a UI-only band and does not participate in keyboard navigation or\nselection. It is a stop-gap until first-class row pinning lands.\n\n## Tracked at\n\n[Missing features](../missing-features.md) → \"row pinning\".\n\n## See also\n\n- [Row spanning](./row-spanning.md)\n- [Full-width rows](./full-width-rows.md)\n"
486
+ },
487
+ {
488
+ "slug": "help/rows/row-sorting",
489
+ "path": "docs/help/rows/row-sorting.md",
490
+ "title": "Row sorting",
491
+ "markdown": "# Row sorting\n\nSorting is a feature you opt into.\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, type ColumnDef,\n } from 'sv-grid-community'\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 'sv-grid-community'\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`SvGridApi` also drives sort via `setSort`. Multi-sort through the API is not\nyet exposed — replace the entire `sorting` state slice via controlled state\nif you need it:\n\n```svelte\n<SvGrid\n {data} {columns} features={features}\n state={{ sorting: [{ id: 'age', desc: true }, { id: 'firstName', desc: false }] }}\n onSortingChange={(s) => (sorting = s)}\n/>\n```\n\n## Disable sort per column\n\nNot yet first-class — there is no `enableSorting: false` field. To prevent\na column from being sortable, do not register `rowSortingFeature`, or wrap\nthe header in a custom component that swallows the click.\n\n## Sorting + server-side data\n\nWhen sort happens on the backend, mark the grid `manualSorting={true}` (see\ngap list — wrapper prop not exposed yet) and round-trip the `sorting` state\nthrough your fetcher. See [demos/09-server-side.svelte](../../../examples/src/demos/09-server-side.svelte).\n\n## See also\n\n- [Filter API](../filtering/filter-api.md)\n- [Server-side guide](../../getting-started.md#11-server-side-data)\n"
492
+ },
493
+ {
494
+ "slug": "help/rows/row-spanning",
495
+ "path": "docs/help/rows/row-spanning.md",
496
+ "title": "Row spanning",
497
+ "markdown": "# Row spanning\n\n\"Row spanning\" lets one cell's content cover **multiple rows**, the way a\nmerged cell does in a spreadsheet.\n\n## Status\n\nRow spanning is **not yet built in**. The grid renders a strict 1-cell-per-\nrow-column grid; there is no `rowSpan` field on `ColumnDef` or\n`CellContext`.\n\n## Workarounds\n\n### 1. Group-by\n\nIf the spanning intent is \"show 'Engineering' once for every engineer\", use\ngrouping ([Row data](./row-data.md), [demos/07](../../../examples/src/demos/07-grouping-aggregation.svelte)) — the grid renders a single group row\nin place of repeated values.\n\n### 2. Cell renderer that suppresses repeats\n\nIf you simply want the *display* of repeated values to be blanked, render\nthe value only when it differs from the row above:\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n {\n field: 'department',\n header: 'Department',\n cell: (ctx) => {\n const data = ctx.table.getRowModel().rows\n const prev = data[ctx.row.index - 1]?.getCellValueByColumnId('department')\n return prev === ctx.getValue() ? '' : ctx.getValue() as string\n },\n },\n]\n```\n\nThis *looks* like a span but does not actually merge cells — keyboard\nnavigation still moves through every row.\n\n## Tracked at\n\n[Missing features](../missing-features.md) → \"row spanning / merged cells\".\n\n## See also\n\n- [Column spanning](../columns/column-spanning.md)\n- [Grouping](./row-data.md)\n"
498
+ },
499
+ {
500
+ "slug": "help/rows/styling-rows",
501
+ "path": "docs/help/rows/styling-rows.md",
502
+ "title": "Styling rows",
503
+ "markdown": "# Styling rows\n\nRows are `<tr role=\"row\">` elements inside the grid table. Style them with\nplain CSS.\n\n## Zebra striping\n\n```css\ntable[role='grid'] tbody tr:nth-child(even) {\n background: var(--sg-row-alt-bg);\n}\n```\n\n## Hover\n\n```css\ntable[role='grid'] tbody tr:hover {\n background: var(--sg-row-hover-bg);\n}\n```\n\n## Selection\n\nA selected row carries `aria-selected=\"true\"`:\n\n```css\ntable[role='grid'] tbody tr[aria-selected='true'] {\n background: var(--sg-selection-bg);\n}\n```\n\n## Conditional row styling\n\nThe cleanest pattern is to attach a class via the leftmost column's `cell`\nrenderer that targets the row from inside it — but the grid doesn't yet\nsupport a `rowClass` callback or `data-*` attribute setter on the row\nelement itself.\n\nToday the workaround is to use a custom cell renderer that includes the\nambient row context to drive its own colour scheme, e.g. a status pill\ncolumn whose colour reflects the row state. Status-driven row colour\nneeds a `rowClass` hook — tracked on the\n[gap list](../missing-features.md).\n\n## CSS custom properties\n\nThe gallery defines these tokens — override at `:root` or on the grid host:\n\n```\n--sg-bg grid background\n--sg-fg grid foreground\n--sg-border cell borders\n--sg-header-bg header background\n--sg-header-fg header foreground\n--sg-row-alt-bg even-row background\n--sg-row-hover-bg hover background\n--sg-selection-bg selected-row background\n--sg-row-height row height (also pass `rowHeight` prop)\n--sg-focus-ring focus outline (box-shadow)\n--sg-accent primary accent (sort arrow, etc)\n```\n\n## See also\n\n- [Row height](./row-height.md)\n- [Custom cells](../cells/cell-components.md)\n"
504
+ },
505
+ {
506
+ "slug": "help/tailwind",
507
+ "path": "docs/help/tailwind.md",
508
+ "title": "Tailwind integration",
509
+ "markdown": "# Tailwind integration\n\nSvGrid was built alongside Tailwind v4 (the gallery is the proof). The\ntwo compose cleanly because they have **non-overlapping concerns**:\n\n- Tailwind styles your *page* - controls, buttons, sidebar, modal,\n spacing, typography.\n- SvGrid ships its own scoped class names (`.sv-grid-*`) and reads\n every visual value from a small set of CSS custom properties\n (`--sg-*`) - so you re-theme it with **CSS variables**, not Tailwind\n utilities.\n\nYou don't put `class=\"bg-slate-50\"` on grid internals. You set\n`--sg-bg: theme(colors.slate.50)` once and the grid inherits.\n\n## Install\n\n```bash\npnpm add -D tailwindcss @tailwindcss/postcss autoprefixer postcss\n```\n\n`postcss.config.cjs`:\n\n```js\nmodule.exports = {\n plugins: {\n '@tailwindcss/postcss': {},\n autoprefixer: {},\n },\n}\n```\n\n`tailwind.config.cjs`:\n\n```js\nmodule.exports = {\n content: ['./index.html', './src/**/*.{ts,svelte}'],\n theme: { extend: {} },\n plugins: [],\n}\n```\n\nYour global stylesheet (`src/index.css`):\n\n```css\n@import 'tailwindcss';\n\n/* See \"Dark mode\" below - Tailwind's `dark:` follows whatever attribute\n * your app uses for theme. The gallery uses html[data-theme='dark']. */\n@custom-variant dark (&:where(html[data-theme='dark'], html[data-theme='dark'] *));\n```\n\nThat's it - the grid's class names ship as part of the component; you\ndon't need a Tailwind plugin or a `safelist` entry.\n\n## The theming surface: `--sg-*` custom properties\n\nSvGrid's stylesheet uses CSS custom properties everywhere a colour, a\nsize, or a hover effect appears. The defaults live in the published\nCSS; you override by declaring the variables at any level **above** the\ngrid (`:root` for the whole app, or on a wrapper `<div>` for one grid\ninstance).\n\nThe full surface (see the gallery's\n[`src/index.css`](../../examples/src/index.css) for live values):\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, scrollbar separators |\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-row-height` | Row height (read by the wrapper's `rowHeight` prop) |\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`, `--sg-pill-pending`, `--sg-pill-inactive` (+ `-fg` variants) | Status pills |\n| `--sg-scrollbar-*` | The custom-painted scrollbars (10+ tokens for arrow / thumb / hover) |\n\nTailwind's `theme(...)` works inside these declarations, so you can\nkeep your design tokens in the Tailwind config and reference them once:\n\n```css\n:root {\n --sg-bg: theme(colors.white);\n --sg-fg: theme(colors.slate.900);\n --sg-border: theme(colors.slate.200);\n --sg-header-bg: theme(colors.slate.100);\n --sg-row-alt-bg: theme(colors.slate.50);\n --sg-row-hover-bg: theme(colors.indigo.50);\n --sg-accent: theme(colors.blue.600);\n}\n```\n\n## Dark mode\n\nThe grid is dark-mode-aware by re-declaring the same tokens under a\nselector for \"dark\":\n\n```css\nhtml[data-theme='dark'] {\n --sg-bg: theme(colors.slate.900);\n --sg-fg: theme(colors.slate.100);\n --sg-border: theme(colors.slate.700);\n --sg-header-bg: theme(colors.slate.800);\n --sg-row-alt-bg: theme(colors.slate.800);\n --sg-row-hover-bg: theme(colors.slate.700);\n --sg-accent: theme(colors.blue.400);\n color-scheme: dark;\n}\n```\n\nThe gallery's `App.svelte` writes the active theme into\n`document.documentElement.dataset.theme`, and the `@custom-variant`\ndeclaration above makes Tailwind's `dark:` modifier follow the same\nattribute. Result: Tailwind utilities and SvGrid tokens flip together.\n\n```svelte\n<button class=\"bg-white dark:bg-slate-900\"> <!-- Tailwind -->\n switch theme\n</button>\n<SvGrid {...props} /> <!-- inherits --sg-* from html[data-theme] -->\n```\n\n## Per-instance theming\n\nBecause the tokens are CSS custom properties they cascade. To restyle\na single grid, wrap it in a `<div>` that sets its own values:\n\n```svelte\n<div style=\"--sg-bg: #ffffff; --sg-accent: #db2777;\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\nThe [`10-custom-cells-and-themes` demo](../../examples/src/demos/10-custom-cells-and-themes.svelte)\nshows this pattern with three full palettes (light / dark / high-contrast)\napplied via a `style=\"...\"` per the user's pick.\n\n## When you *do* need to override a class\n\nSome things aren't tokens - column-resize handle width, the funnel\nbutton hover opacity, pill paddings. The grid's class names are\ndeliberately stable so you can target them from your global CSS\n(NOT through `@apply` - the grid lives outside the Tailwind\npurge pass):\n\n```css\n.sv-grid-resize-handle {\n width: 8px; /* default is 5px */\n}\n\n.sv-grid-col-filter-btn {\n opacity: 0; /* hide funnels unless hovered */\n}\n.sv-grid-column:hover .sv-grid-col-filter-btn,\n.sv-grid-col-filter-btn.is-active {\n opacity: 1;\n}\n\n.sv-grid-cell[data-align='right'] {\n font-variant-numeric: tabular-nums;\n}\n```\n\nAdd these rules to your global stylesheet, *after* `@import 'tailwindcss';`,\nso Tailwind's preflight + utilities load first and your overrides win\non equal-specificity ties.\n\n## Anti-patterns\n\n**Don't put Tailwind utility classes on the grid's children.** The\ndefault renderer owns those nodes; your classes will get clobbered on\nre-render or row-virtualisation recycle. Use the `--sg-*` tokens or\ntarget the stable `.sv-grid-*` class names.\n\n**Don't `@apply` inside grid selectors.** `@apply` reads Tailwind's\npreflight scope. Mixed with the grid's component-scoped CSS the\nspecificity gets weird. Plain property declarations (`background:\ntheme(colors.slate.50)`) are more predictable.\n\n**Don't fight the column widths in CSS.** Set them in your\n`ColumnDef`s. The wrapper uses `style=\"width: Npx; min-width: Npx;\nmax-width: Npx;\"` and the layout will not respond to a Tailwind utility\non the `<th>`.\n\n## See also\n\n- [Why headless?](../why-headless.md) - the architectural reason the\n theming surface looks like this\n- [Getting started](../getting-started.md) - end-to-end gallery setup\n- [Styling cells](./cells/styling-cells.md) - cell-level overrides\n- [Styling rows](./rows/styling-rows.md) - row-level overrides\n- Demo [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte) -\n three palettes applied via `style=\"--sg-*: ...\"`\n"
510
+ },
511
+ {
512
+ "slug": "help/testing-and-quality",
513
+ "path": "docs/help/testing-and-quality.md",
514
+ "title": "Testing & Quality",
515
+ "markdown": "# Testing & Quality\n\nSvGrid ships with a comprehensive automated test suite. This page is the\nhonest accounting of what we test, what we don't, and where coverage\nstands today.\n\n## Headline numbers (v1.0)\n\n> **92.2% line coverage** on the testable surface\n> (`pnpm --filter sv-grid-community test:lib`)\n\n| Metric | Coverage | Threshold |\n| ------ | -------- | --------- |\n| Lines | 92.20% | ≥ 90% |\n| Statements | 90.94% | ≥ 90% |\n| Branches | 82.21% | ≥ 75% |\n| Functions | 82.87% | ≥ 80% |\n\nRun the suite locally:\n\n```bash\npnpm test # alias for: pnpm --filter sv-grid-community test:lib\npnpm test:types # svelte-check on every package\n```\n\nThe full coverage report lands in\n`packages/sv-grid-community/coverage/index.html`.\n\n## What's measured\n\nThe **testable surface** is the headless engine, helpers, and pure logic\nfunctions:\n\n- `core.ts` (createSvGrid, row models, sortFns, filterFns) — ≥ 89% lines\n- `a11y.ts` (ARIA prop builders) — 100% lines\n- `keyboard.ts` (intent + next-cell math) — 100% lines\n- `cell-formatting.ts` (locale / currency / percent / date helpers) — 100% lines\n- `editors/cell-editors.ts` (parseEditorValue for every editor type) — 100% lines\n- `filtering/excel-filters.ts` (every Excel-style operator + edge cases) — 100% lines\n- `render-component.ts` (renderSnippet / renderComponent factories) — 100% lines\n- `subscribe.ts` (store subscription + shallow-compare) — 100% lines\n- `virtualization/*` — ≥ 86% lines\n\n## What's measured separately\n\nTwo files are tested via **behavioral mount tests** rather than line coverage\nbecause their branches depend on real browser layout (offsetWidth, scroll\ndimensions, ResizeObserver fires) that jsdom returns as zero:\n\n- **`SvGrid.svelte`** — the 4000-line render component. Covered by **60+\n behavioral mount tests** across\n [`svgrid.behavior.test.ts`](https://github.com/sv-grid/sv-grid/blob/main/packages/sv-grid-community/src/svgrid.behavior.test.ts),\n [`svgrid.interaction.test.ts`](https://github.com/sv-grid/sv-grid/blob/main/packages/sv-grid-community/src/svgrid.interaction.test.ts),\n and\n [`svgrid.api.test.ts`](https://github.com/sv-grid/sv-grid/blob/main/packages/sv-grid-community/src/svgrid.api.test.ts).\n Each test mounts the real `<SvGrid />` in jsdom and exercises a specific\n feature: sort, filter, pagination, inline editing, cell selection,\n grouping, row selection, column add/remove, keyboard navigation, etc.\n- **`sv-grid-scrollbar.ts`** — a custom element that paints scrollbar\n glyphs from layout measurements. Its paint loop runs in a real browser;\n jsdom can't exercise it.\n\n## What's excluded\n\nThe coverage report excludes:\n\n- `SvGrid.svelte` (covered behaviorally — see above)\n- `FlexRender.svelte` (covered by `flex-render.test.ts` + every SvGrid mount)\n- `sv-grid-scrollbar.ts` (custom element)\n- `static-functions.ts` (pure re-exports)\n- `createGridState.svelte.ts` (downstream-adapter thin layer)\n- `test-fixtures/**`, `test-setup.ts`, `**/*.test.ts`, `**/*.d.ts`\n\nThe exclusion list is part of `packages/sv-grid-community/vite.config.ts`\nand is documented inline with the reasoning for each entry.\n\n## Test files\n\n| File | Surface | Tests |\n| ---- | ------- | ----- |\n| `createGrid.test.ts` | Headless `createSvGrid` instance | Unit |\n| `svgrid.features.test.ts` | Row-model composition (core → filter → sort → group → expand → paginate) | Integration |\n| `svgrid.api.test.ts` | The imperative `SvGridApi` exposed via `onApiReady` | Mounted |\n| `svgrid.behavior.test.ts` | Wide behavior coverage: 30+ scenarios mounting the real component | Mounted |\n| `svgrid.interaction.test.ts` | Keyboard / pointer / scroll / edit events | Mounted |\n| `svgrid.wrapper.test.ts` | Source-string safety net | Static |\n| `svgrid.features.test.ts` | Feature composition + state hydration | Headless |\n| `core.coverage.test.ts` | Row / cell lazy getters, sortFns, filterFns, grouping | Unit |\n| `cell-formatting.test.ts` | Locale / currency / percent / date helpers | Unit |\n| `subscribe.test.ts` | Store subscription + shallowCompare | Unit |\n| `render-component.test.ts` | renderSnippet / renderComponent factories | Unit |\n| `flex-render.test.ts` | `<FlexRender />` discriminator (string / fn / config) | Mounted |\n| `editors/cell-editors.test.ts` | `parseEditorValue` per editor type | Unit |\n| `filtering/excel-filters.test.ts` | Every operator + every edge case | Unit |\n| `keyboard.test.ts` | `getKeyboardIntent` / `getNextActiveCell` | Pure unit |\n| `a11y.test.ts`, `a11y.contract.test.ts` | ARIA prop builders + contract | Pure unit |\n| `core.performance.test.ts` | Engine performance under large row counts | Benchmark |\n\nTotal: **168 tests** across **14 test files**.\n\n## Quality controls beyond unit tests\n\n- **TypeScript strict mode** across both packages. `pnpm test:types`\n must pass on every PR (currently 0 errors / 0 warnings).\n- **ESLint** at `pnpm lint`, with the Svelte plugin.\n- **Publint** at `pnpm --filter sv-grid-community test:build` checks the\n published `exports` map.\n- **CSP-strict runtime**: no `eval`, no `new Function`, no inline scripts.\n Demo `16-csp-compliant` includes a runtime self-check.\n- **SSR snapshot**: demo `19-ssr` proves the grid renders meaningful HTML\n before hydration.\n- **Accessibility contracts**: `a11y.contract.test.ts` asserts that root,\n row, header, and cell prop builders produce a consistent ARIA tree.\n- **Mount-based behavioral tests** mount the real `<SvGrid />` in jsdom\n with polyfilled `ResizeObserver` / `IntersectionObserver` / `scrollIntoView`\n and exercise the imperative API end-to-end.\n\n## How to contribute a test\n\n1. Pick a behavior you want to lock down. Bias toward\n *\"user does X, grid does Y\"* over *\"function Z returns W\"*.\n2. If the behavior involves the rendered DOM, mount the component using\n the pattern in `svgrid.api.test.ts`:\n ```ts\n import { mount, unmount } from 'svelte'\n import SvGrid from './SvGrid.svelte'\n\n const target = document.createElement('div')\n document.body.appendChild(target)\n const app = mount(SvGrid, {\n target,\n props: { data, columns, features, onApiReady: (a) => { api = a } },\n })\n // exercise + assert\n unmount(app)\n ```\n3. If the behavior is pure (a row model, a sort comparator, an a11y prop\n builder), add to one of the existing unit-test files.\n4. Run `pnpm --filter sv-grid-community exec vitest run <file>` to iterate\n fast.\n5. Open the PR; include the before/after coverage delta in the description.\n\n## CI\n\nThe deploy workflow (`.github/workflows/deploy-website.yml`) currently\nbuilds the library and the website. The next step is to add a\n**Test workflow** that runs `pnpm test` + `pnpm test:types` on every PR\nand posts the coverage delta as a comment.\n"
516
+ },
517
+ {
518
+ "slug": "why-headless",
519
+ "path": "docs/why-headless.md",
520
+ "title": "Why headless?",
521
+ "markdown": "# Why headless?\n\nSvGrid is **headless at the core**, with a fully-styled Svelte component\nshipped on top. That two-layer split is deliberate, and worth\nunderstanding before you reach for either.\n\n## What \"headless\" actually means here\n\nThe core - `createSvGrid` from `sv-grid-community/core` - knows about\nrows, columns, sorting, filtering, grouping, pagination, expansion, and\nselection. It does **not** know about pixels, DOM, ARIA, or CSS. It is\na state machine over your data that you query and mutate from Svelte.\n\nThe component - `<SvGrid>` - is one (opinionated) way to render that\nstate machine into a `<table>`. It is itself written against the\nheadless core, so the same hooks are available to you if you want to\nwrite your own renderer.\n\n```text\n┌──────────────────────────────────────────────────────┐\n│ Your app │\n└───────────────┬──────────────────────────────────────┘\n │\n ▼\n ┌─────────────────────┐\n │ <SvGrid> (Svelte) │ ← default renderer, ARIA,\n │ FlexRender │ keyboard, drag handles,\n │ formatters, menus │ theme tokens\n └─────────┬───────────┘\n │\n ▼\n ┌─────────────────────┐\n │ createSvGrid() │ ← rows × cols × state\n │ row models │ sort / filter / page /\n │ features │ group / expand\n └─────────────────────┘\n```\n\n## What you get from headless\n\n**1. The renderer is replaceable.** Want a virtualised React grid? A\ncanvas-based renderer for 1 M rows? A read-only `<table>` for a printed\nreport? `createSvGrid` returns the same state machine for all of them.\nYou write the markup, you keep the headless brain.\n\n```ts\nimport { createSvGrid, createCoreRowModel, createSortedRowModel,\n tableFeatures, rowSortingFeature, sortFns } from 'sv-grid-community'\n\nconst grid = createSvGrid({\n _features: tableFeatures({ rowSortingFeature }),\n _rowModels: {\n coreRowModel: createCoreRowModel(),\n sortedRowModel: createSortedRowModel(sortFns),\n },\n columns,\n data,\n})\n\n// Your own render loop:\nfor (const row of grid.getRowModel().rows) {\n for (const cell of row.getAllCells()) drawCell(cell)\n}\n```\n\n**2. Features are opt-in modules.** Monolithic grid libraries ship everything\nin one bundle. With SvGrid you only register what you use:\n\n```ts\nimport { tableFeatures, rowSortingFeature } from 'sv-grid-community'\n\n// no filtering, no grouping, no pagination - none of that code is\n// reachable from this grid instance\nconst features = tableFeatures({ rowSortingFeature })\n```\n\nThe features object is the contract the headless core checks for\noptional capabilities. Each feature ships a small chunk of state +\nhelpers; if it's not in `tableFeatures()`, the core never asks for it\nand Vite tree-shakes away the rest.\n\n**3. Tests are fast and DOM-free.** `createSvGrid` runs without a\nbrowser:\n\n```ts\nimport { createSvGrid, ... } from 'sv-grid-community'\n\ntest('sorts by salary descending', () => {\n const grid = createSvGrid({ ..., state: { sorting: [{ id: 'salary', desc: true }] } })\n const rows = grid.getRowModel().rows\n expect(rows[0]!.getValue('salary')).toBeGreaterThan(rows[1]!.getValue('salary'))\n})\n```\n\nNo JSDOM, no Playwright, no test renderer. The headless contract is the\nunit of test.\n\n**4. Server-side rendering is a non-feature.** Because the core has no\nDOM, you can call `grid.getRowModel().rows` inside a SvelteKit\n`+page.server.ts` and pre-bake the table HTML before it ever reaches\nthe browser. Demo [`19-ssr`](../examples/src/demos/19-ssr.svelte)\nwalks through that.\n\n**5. State is yours to own.** Sort clauses, filter predicates, expansion\nstate, selection state - all of it lives in a `store` you can serialise\nto a URL, sync to a query string, or restore from `localStorage`. The\ndefault `<SvGrid>` wires this up for you, but the wires are visible:\n\n```ts\n// Persist\nlocalStorage.setItem('grid', JSON.stringify(grid.getState()))\n\n// Restore\nconst saved = JSON.parse(localStorage.getItem('grid')!)\ngrid.store.setState((prev) => ({ ...prev, ...saved }))\n```\n\n## When the wrapper is the right tool anyway\n\nYou won't usually write a custom renderer. `<SvGrid>` is the default\nbecause the 80% case is \"I want a table, with sort and filter, that\nlooks correct\". The wrapper:\n\n- handles WAI-ARIA grid semantics, keyboard navigation, focus\n management, copy/paste, range selection;\n- wires virtualisation, column resize, fit-to-width, pinning, the\n filter menu, the column menu, the row-number column;\n- exposes a `SvGridApi` for data + columns + sort + filter +\n visibility mutations;\n- emits callbacks (`onSortingChange`, `onFiltersChange`,\n `onRowSelectionChange`, `onCellValueChange`) for parents that want to\n observe.\n\nReach for the headless core when:\n\n- you need a renderer the default cannot produce (canvas, mobile-only,\n Excel-export-only),\n- you're embedding the grid in an environment without a real DOM (SSR,\n static-site generators, PDF pipelines),\n- you want to drive multiple coordinated grids from one state store,\n- you're building a higher-level abstraction on top of SvGrid and want\n the headless API as your foundation.\n\n## The trade-off, named\n\nHeadless costs you a default theme. You can't `npm install` a\n\"complete-looking grid\" and have it match your app out of the box;\nevery grid library that promises that has to ship CSS and DOM\nassumptions you'll eventually fight.\n\nSvGrid splits the difference: the headless core is its own thing, and\n`<SvGrid>` is a *reference renderer* you can copy and modify. The\nshipped CSS uses `--sg-*` custom properties so you can re-theme it\nwithout forking. See [Tailwind integration](./help/tailwind.md) for a\nworked example.\n\n## See also\n\n- [Getting started](./getting-started.md) - the wrapper-first walkthrough\n- [Column definitions](./help/columns/column-definitions.md) - the contract the headless core enforces\n- [Filter API](./help/filtering/filter-api.md) - example of headless state surfaced through the wrapper\n- [`createSvGrid` source](../packages/sv-grid-community/src/createGrid.svelte.ts)\n- Demo [`19-ssr`](../examples/src/demos/19-ssr.svelte) - SSR with the headless core\n"
522
+ }
523
+ ];
524
+ export const apiReference = {
525
+ "components": [
526
+ "SvGrid",
527
+ "FlexRender",
528
+ "renderComponent",
529
+ "renderSnippet"
530
+ ],
531
+ "headless": [
532
+ "createSvGrid",
533
+ "createGrid",
534
+ "createGridState",
535
+ "subscribeGrid",
536
+ "createTable"
537
+ ],
538
+ "rowModels": [
539
+ "createCoreRowModel",
540
+ "createFilteredRowModel",
541
+ "createSortedRowModel",
542
+ "createGroupedRowModel",
543
+ "createExpandedRowModel",
544
+ "createPaginatedRowModel"
545
+ ],
546
+ "features": [
547
+ "tableFeatures",
548
+ "rowSortingFeature",
549
+ "columnFilteringFeature",
550
+ "columnGroupingFeature",
551
+ "rowExpandingFeature",
552
+ "rowPaginationFeature",
553
+ "rowSelectionFeature"
554
+ ],
555
+ "virtualization": [
556
+ "createVirtualizer",
557
+ "createSvelteVirtualizer",
558
+ "createColumnVirtualizer"
559
+ ],
560
+ "accessibility": [
561
+ "getGridRootA11yProps",
562
+ "getGridHeaderA11yProps",
563
+ "getGridCellA11yProps",
564
+ "getGridRowA11yProps",
565
+ "getGridCellDomId"
566
+ ],
567
+ "utilities": [
568
+ "getKeyboardIntent",
569
+ "getNextActiveCell",
570
+ "parseEditorValue",
571
+ "applyExcelFilter",
572
+ "formatNumericWithConfig",
573
+ "resolveDatePattern"
574
+ ]
575
+ };