@svgrid/mcp 3.0.8 → 3.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/data.js CHANGED
@@ -1021,7 +1021,7 @@ export const examples = [
1021
1021
  "title": "Column Pinning",
1022
1022
  "category": "Columns",
1023
1023
  "blurb": "25. Column pinning + freezing Wide grid (8 generous columns ~2050px total) so horizontal scrolling kicks in on any reasonable viewport. Company is pre-pinned to the",
1024
- "source": "<!-- Documented in: docs/help/recipes.md -->\n<script lang=\"ts\">\n /**\n * 25. Column pinning + freezing\n * -----------------------------\n * Wide grid (8 generous columns ~2050px total) so horizontal scrolling\n * kicks in on any reasonable viewport. Company is pre-pinned to the\n * LEFT and Unit price to the RIGHT - they stay sticky while the middle\n * columns slide under them when you scroll.\n *\n * Try it:\n * - Scroll the grid horizontally - Company stays anchored on the left,\n * Unit price on the right, the rest scroll between them.\n * - Hover any middle-column header → click the ⋮ that appears →\n * \"Pin to left\" / \"Pin to right\" / \"Unpin column\".\n *\n * Note: column pinning requires `columnVirtualization={false}` because\n * the virtualizer recycles DOM nodes (which breaks sticky positioning).\n * The pin menu items are hidden when virtualization is on, so the\n * gating is automatic.\n *\n * A programmatic `api.setColumnPinning(id, side)` is on the v1.x\n * roadmap; today the `initialColumnPinning` prop covers the on-mount\n * case and the column menu covers user-driven re-pinning.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type GridColumns,\n } from '@svgrid/grid'\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(500))\n\n // 8 generous columns - total ~2050px so the grid overflows horizontally\n // on any normal monitor. Pinning is only meaningful when the grid\n // actually scrolls; the demo has to force that condition to demonstrate\n // the feature.\n const columns: GridColumns<Order> = [\n { field: 'company', header: 'Company', width: 240 },\n { field: 'product', header: 'Product', width: 320 },\n { field: 'orderId', header: 'Order ID', width: 180 },\n { field: 'country', header: 'Country', width: 200 },\n { field: 'sellDate', header: 'Sell date', width: 220,\n format: { type: 'date', pattern: 'y-m-d' } },\n { field: 'quantity', header: 'Quantity', width: 170,\n format: { type: 'number', options: { maximumFractionDigits: 0 } } },\n { field: 'inStock', header: 'In stock', width: 160 },\n { field: 'price', header: 'Unit price', width: 200,\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 Company is pinned LEFT · Unit price is pinned RIGHT\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 <strong>Scroll horizontally</strong> - Company and Unit price stay anchored at the edges\n while everything else slides between them.\n </li>\n <li>\n <strong>Pin another column</strong> - hover any middle header → click the\n <code style=\"color: var(--site-accent-2, #22d3ee);\">⋮</code> → pick\n <em>Pin to left</em>, <em>Pin to right</em>, or <em>Unpin column</em>.\n </li>\n </ol>\n </div>\n\n <div class=\"flex-1 min-h-0 pinning-host\">\n <SvGrid responsive={true}\n columnResize\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showRowNumbers={true}\n showPagination={true}\n pageSize={50}\n enableCellSelection={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={false}\n columnVirtualization={false}\n initialColumnPinning={{ left: ['company'], right: ['price'] }}\n />\n </div>\n\n <footer class=\"pin-foot text-xs shrink-0\">\n {rows.length} rows · {columns.length} columns ·\n <code>fitColumns=&lbrace;false&rbrace;</code> +\n <code>columnVirtualization=&lbrace;false&rbrace;</code>\n so the grid keeps its declared widths and the sticky pinned columns work.\n </footer>\n</section>\n\n<style>\n .pin-foot { color: var(--sg-muted, #64748b); }\n\n /* Punch up the visual contrast for pinned columns so the user can\n immediately see which columns are frozen. The grid already supports\n these tokens; this demo just turns the dial higher to demo the\n feature. */\n .pinning-host {\n /* Body cells: a stronger accent-tinted background than the default\n 8% mix - the pinned strip should read as a clearly different surface.\n The two color-mix percentages MUST sum to 100% - otherwise CSS scales\n the result's alpha down (e.g. 55% + 18% = 73% -> 0.73 alpha), which\n makes the pinned column semi-transparent and lets the scrolling\n middle columns bleed through. */\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg, #ffffff) 82%, var(--sg-accent, #2563eb) 18%);\n /* Header cells: even bolder so the frozen header is unmistakable. */\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #f1f5f9) 70%, var(--sg-accent, #2563eb) 30%);\n /* Divider line on the inside edge - solid accent so it reads as\n intentional, not as a normal cell border. */\n --sg-pinned-divider: var(--sg-accent, #2563eb);\n }\n :global(html[data-theme='dark']) .pinning-host {\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg, #181d27) 76%, var(--sg-accent, #3b82f6) 24%);\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #1e2433) 64%, var(--sg-accent, #3b82f6) 36%);\n --sg-pinned-divider: var(--sg-accent, #3b82f6);\n }\n\n /* No icon - the accent-tinted background tokens above (--sg-pinned-bg\n / --sg-pinned-header-bg) carry the \"this column is frozen\" signal\n on their own. Punch the header tint further to make it unmistakable\n even at a glance. */\n .pinning-host {\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #f1f5f9) 58%, var(--sg-accent, #2563eb) 42%);\n }\n :global(html[data-theme='dark']) .pinning-host {\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #1e2433) 52%, var(--sg-accent, #3b82f6) 48%);\n }\n /* Make the pinned header text white on the now-strong accent fill so\n contrast stays readable in both modes. */\n .pinning-host :global(.sv-grid-head .sv-grid-column[data-pinned]) {\n color: #fff;\n }\n .pinning-host :global(.sv-grid-head .sv-grid-column[data-pinned] *) {\n color: inherit;\n }\n</style>\n"
1024
+ "source": "<!-- Documented in: docs/help/recipes.md -->\n<script lang=\"ts\">\n /**\n * 25. Column pinning + freezing\n * -----------------------------\n * Wide grid (8 generous columns ~2050px total) so horizontal scrolling\n * kicks in on any reasonable viewport. Company is pre-pinned to the\n * LEFT and Unit price to the RIGHT - they stay sticky while the middle\n * columns slide under them when you scroll.\n *\n * Try it:\n * - Scroll the grid horizontally - Company stays anchored on the left,\n * Unit price on the right, the rest scroll between them.\n * - Hover any middle-column header → click the ⋮ that appears →\n * \"Pin to left\" / \"Pin to right\" / \"Unpin column\".\n *\n * Pinning works with column virtualization on (the default): the pinned\n * columns render on their own either side of the virtual window, so only\n * the columns in view plus the pinned ones are in the DOM.\n *\n * A programmatic `api.setColumnPinning(id, side)` is on the v1.x\n * roadmap; today the `initialColumnPinning` prop covers the on-mount\n * case and the column menu covers user-driven re-pinning.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type GridColumns,\n } from '@svgrid/grid'\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(500))\n\n // 8 generous columns - total ~2050px so the grid overflows horizontally\n // on any normal monitor. Pinning is only meaningful when the grid\n // actually scrolls; the demo has to force that condition to demonstrate\n // the feature.\n const columns: GridColumns<Order> = [\n { field: 'company', header: 'Company', width: 240 },\n { field: 'product', header: 'Product', width: 320 },\n { field: 'orderId', header: 'Order ID', width: 180 },\n { field: 'country', header: 'Country', width: 200 },\n { field: 'sellDate', header: 'Sell date', width: 220,\n format: { type: 'date', pattern: 'y-m-d' } },\n { field: 'quantity', header: 'Quantity', width: 170,\n format: { type: 'number', options: { maximumFractionDigits: 0 } } },\n { field: 'inStock', header: 'In stock', width: 160 },\n { field: 'price', header: 'Unit price', width: 200,\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 Company is pinned LEFT · Unit price is pinned RIGHT\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 <strong>Scroll horizontally</strong> - Company and Unit price stay anchored at the edges\n while everything else slides between them.\n </li>\n <li>\n <strong>Pin another column</strong> - hover any middle header → click the\n <code style=\"color: var(--site-accent-2, #22d3ee);\">⋮</code> → pick\n <em>Pin to left</em>, <em>Pin to right</em>, or <em>Unpin column</em>.\n </li>\n </ol>\n </div>\n\n <div class=\"flex-1 min-h-0 pinning-host\">\n <SvGrid responsive={true}\n columnResize\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showRowNumbers={true}\n showPagination={true}\n pageSize={50}\n enableCellSelection={true}\n rowHeight={36}\n containerHeight=\"100%\"\n fitColumns={false}\n initialColumnPinning={{ left: ['company'], right: ['price'] }}\n />\n </div>\n\n <footer class=\"pin-foot text-xs shrink-0\">\n {rows.length} rows · {columns.length} columns ·\n <code>fitColumns=&lbrace;false&rbrace;</code> +\n <code>columnVirtualization=&lbrace;false&rbrace;</code>\n so the grid keeps its declared widths and the sticky pinned columns work.\n </footer>\n</section>\n\n<style>\n .pin-foot { color: var(--sg-muted, #64748b); }\n\n /* Punch up the visual contrast for pinned columns so the user can\n immediately see which columns are frozen. The grid already supports\n these tokens; this demo just turns the dial higher to demo the\n feature. */\n .pinning-host {\n /* Body cells: a stronger accent-tinted background than the default\n 8% mix - the pinned strip should read as a clearly different surface.\n The two color-mix percentages MUST sum to 100% - otherwise CSS scales\n the result's alpha down (e.g. 55% + 18% = 73% -> 0.73 alpha), which\n makes the pinned column semi-transparent and lets the scrolling\n middle columns bleed through. */\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg, #ffffff) 82%, var(--sg-accent, #2563eb) 18%);\n /* Header cells: even bolder so the frozen header is unmistakable. */\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #f1f5f9) 70%, var(--sg-accent, #2563eb) 30%);\n /* Divider line on the inside edge - solid accent so it reads as\n intentional, not as a normal cell border. */\n --sg-pinned-divider: var(--sg-accent, #2563eb);\n }\n :global(html[data-theme='dark']) .pinning-host {\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg, #181d27) 76%, var(--sg-accent, #3b82f6) 24%);\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #1e2433) 64%, var(--sg-accent, #3b82f6) 36%);\n --sg-pinned-divider: var(--sg-accent, #3b82f6);\n }\n\n /* No icon - the accent-tinted background tokens above (--sg-pinned-bg\n / --sg-pinned-header-bg) carry the \"this column is frozen\" signal\n on their own. Punch the header tint further to make it unmistakable\n even at a glance. */\n .pinning-host {\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #f1f5f9) 58%, var(--sg-accent, #2563eb) 42%);\n }\n :global(html[data-theme='dark']) .pinning-host {\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-header-bg, #1e2433) 52%, var(--sg-accent, #3b82f6) 48%);\n }\n /* Make the pinned header text white on the now-strong accent fill so\n contrast stays readable in both modes. */\n .pinning-host :global(.sv-grid-head .sv-grid-column[data-pinned]) {\n color: #fff;\n }\n .pinning-host :global(.sv-grid-head .sv-grid-column[data-pinned] *) {\n color: inherit;\n }\n</style>\n"
1025
1025
  },
1026
1026
  {
1027
1027
  "id": "250-calendar",
@@ -2365,7 +2365,7 @@ export const examples = [
2365
2365
  "title": "Healthcare Emr",
2366
2366
  "category": "Industry Templates",
2367
2367
  "blurb": "41. Healthcare EMR - inpatient board An ICU / inpatient list as it would appear to a clinician. Built to show the procurement team's healthcare buyer two things at",
2368
- "source": "<!-- Documented in: docs/help/recipes.md -->\n<script lang=\"ts\">\n /**\n * 41. Healthcare EMR - inpatient board\n * ------------------------------------\n * An ICU / inpatient list as it would appear to a clinician. Built\n * to show the procurement team's healthcare buyer two things at\n * once:\n *\n * 1. **The grid renders rich clinical cells.** Vitals sparkline,\n * color-graded risk score, allergy chip cluster, code-status\n * pill, primary-nurse avatar - all in stock SvGrid cell\n * snippets, no plugin layer.\n *\n * 2. **Role-based access actually works.** The role switcher\n * drives the cell-level `editable: (ctx) => boolean` predicate\n * the grid added in demo 35. Nurses can edit notes; physicians\n * can move code status and orders; admins also see discharge\n * planning fields. Try Viewer to see everything lock.\n *\n * No PHI: every name, MRN, allergy, and diagnosis is synthetic\n * and deterministically seeded. The structure is what counts.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n renderSnippet,\n type ColumnDef,\n type CellContext,\n } from '@svgrid/grid'\n\n // ---- Domain ---------------------------------------------------------\n\n type CodeStatus = 'full_code' | 'dnr' | 'dni' | 'comfort_only'\n type Risk = 'low' | 'moderate' | 'high' | 'critical'\n type Role = 'viewer' | 'nurse' | 'physician' | 'admin'\n\n type Allergy = string\n type Patient = {\n mrn: string\n initials: string\n age: number\n sex: 'F' | 'M'\n room: string\n bed: string\n los: number\n diagnosis: string\n risk: Risk\n riskScore: number // 0–100\n codeStatus: CodeStatus\n allergies: Allergy[]\n attending: string\n primaryNurse: string\n notes: string\n /** 30-point trailing vitals (heart rate). */\n hrTrend: number[]\n /** Latest values for the trend tile. */\n hr: number\n spo2: number\n sbp: number // systolic BP\n }\n\n // ---- Seed -----------------------------------------------------------\n\n const FIRST_INITIALS = ['A.M.', 'J.K.', 'R.S.', 'M.O.', 'L.P.', 'D.B.', 'C.T.', 'N.E.', 'S.H.', 'K.Y.']\n const DIAGNOSES = [\n 'Sepsis, unspecified', 'ARDS w/ mechanical vent', 'Post-op cabg',\n 'Septic shock', 'STEMI, anterior', 'Acute pancreatitis',\n 'Cerebrovascular accident', 'Pneumonia, MRSA', 'GI bleed, lower',\n 'DKA, T1DM', 'Pulmonary embolism', 'Renal failure, AKI',\n ]\n const ALLERGY_POOL = ['Penicillin', 'Latex', 'Sulfa', 'Iodine', 'Codeine', 'Aspirin', 'NSAIDs', 'Shellfish', 'Eggs']\n const ATTENDINGS = ['Dr. Park', 'Dr. Singh', 'Dr. Cohen', 'Dr. Nakamura', 'Dr. Mehta', 'Dr. Diaz']\n const NURSES = ['Tran', 'Olsen', 'Wu', 'Khan', 'Diaz', 'Park', 'Nair', 'Silva']\n const CODE_STATUSES: readonly CodeStatus[] = ['full_code', 'dnr', 'dni', 'comfort_only']\n\n let prng = 0xC0CA0DE5\n function rnd(): number {\n prng = (prng * 1664525 + 1013904223) >>> 0\n return prng / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rnd() * arr.length)]! }\n\n function seedPatient(i: number): Patient {\n const initials = pick(FIRST_INITIALS)\n const age = 18 + Math.floor(rnd() * 80)\n const sex: 'F' | 'M' = rnd() < 0.5 ? 'F' : 'M'\n const riskScore = Math.floor(rnd() * 100)\n const risk: Risk =\n riskScore >= 80 ? 'critical' :\n riskScore >= 60 ? 'high' :\n riskScore >= 30 ? 'moderate' : 'low'\n const baseHr = 60 + Math.floor(rnd() * 70)\n const hrTrend: number[] = []\n let hr = baseHr\n for (let k = 0; k < 30; k += 1) {\n hr = Math.max(45, Math.min(160, hr + (rnd() - 0.5) * 8))\n hrTrend.push(Math.round(hr))\n }\n const allergyCount = Math.floor(rnd() * 3)\n const allergies: Allergy[] = []\n while (allergies.length < allergyCount) {\n const a = pick(ALLERGY_POOL)\n if (!allergies.includes(a)) allergies.push(a)\n }\n return {\n mrn: `M${(100_000 + i).toString().slice(-6)}`,\n initials,\n age, sex,\n room: `${pick(['SI', 'CC', 'MS'])}-${100 + Math.floor(rnd() * 30)}`,\n bed: pick(['A', 'B']),\n los: 1 + Math.floor(rnd() * 14),\n diagnosis: pick(DIAGNOSES),\n risk,\n riskScore,\n codeStatus: pick(CODE_STATUSES),\n allergies,\n attending: pick(ATTENDINGS),\n primaryNurse: pick(NURSES),\n notes: '',\n hrTrend,\n hr: hrTrend[hrTrend.length - 1]!,\n spo2: 88 + Math.floor(rnd() * 11),\n sbp: 90 + Math.floor(rnd() * 60),\n }\n }\n function seedRoster(n: number): Patient[] {\n const out: Patient[] = []\n for (let i = 0; i < n; i += 1) out.push(seedPatient(i))\n return out\n }\n\n // ---- State ---------------------------------------------------------\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let patients = $state<Patient[]>(seedRoster(28))\n let role = $state<Role>('physician')\n\n // ---- Role permission rules -----------------------------------------\n\n const NURSE_EDITABLE = new Set(['notes', 'primaryNurse'])\n const PHYS_EDITABLE = new Set(['notes', 'primaryNurse', 'attending', 'codeStatus', 'diagnosis', 'allergies'])\n const ADMIN_EXTRA = new Set(['room', 'bed', 'los'])\n function canEditColumn(columnId: string): boolean {\n if (role === 'viewer') return false\n if (role === 'admin') return PHYS_EDITABLE.has(columnId) || ADMIN_EXTRA.has(columnId)\n if (role === 'physician') return PHYS_EDITABLE.has(columnId)\n return NURSE_EDITABLE.has(columnId)\n }\n function canSeeColumn(columnId: string): boolean {\n if (columnId === 'discharge') return role === 'admin'\n return true\n }\n\n function onCellValueChange(args: { rowIndex: number; columnId: string; newValue: unknown }): void {\n const patient = patients[args.rowIndex]\n if (!patient) return\n if (!canEditColumn(args.columnId)) return\n const next = patients.slice()\n next[args.rowIndex] = { ...patient, [args.columnId]: args.newValue } as Patient\n patients = next\n }\n\n // ---- KPI derivations ----------------------------------------------\n\n const kpis = $derived.by(() => {\n const total = patients.length\n const critical = patients.filter((p) => p.risk === 'critical').length\n const high = patients.filter((p) => p.risk === 'high').length\n const dnr = patients.filter((p) => p.codeStatus === 'dnr').length\n const avgRisk = total ? Math.round(patients.reduce((s, p) => s + p.riskScore, 0) / total) : 0\n const avgLos = total ? +(patients.reduce((s, p) => s + p.los, 0) / total).toFixed(1) : 0\n return { total, critical, high, dnr, avgRisk, avgLos }\n })\n\n // ---- Sparkline helpers --------------------------------------------\n\n function sparkPath(history: number[], w: number, h: number): string {\n const min = Math.min(...history)\n const max = Math.max(...history)\n const range = Math.max(1e-6, max - min)\n const step = w / (history.length - 1)\n let d = ''\n for (let i = 0; i < history.length; i += 1) {\n const x = Math.round(i * step * 100) / 100\n const y = Math.round((h - ((history[i]! - min) / range) * h) * 100) / 100\n d += `${i === 0 ? 'M' : 'L'}${x} ${y} `\n }\n return d.trim()\n }\n</script>\n\n<!-- ───────────────────── CELL SNIPPETS ───────────────────── -->\n\n{#snippet MrnCell(props: { row: Patient })}\n <span class=\"hc-mrn-wrap\">\n <span class=\"mono\">{props.row.mrn}</span>\n <span class=\"hc-mrn-init\">{props.row.initials}</span>\n </span>\n{/snippet}\n\n{#snippet AgeSexCell(props: { row: Patient })}\n <span class=\"hc-age\">{props.row.age}<span class=\"hc-sex\">{props.row.sex}</span></span>\n{/snippet}\n\n{#snippet LocationCell(props: { row: Patient })}\n <span class=\"hc-location\">\n <span class=\"hc-room\">{props.row.room}</span>\n <span class=\"hc-bed\">{props.row.bed}</span>\n </span>\n{/snippet}\n\n{#snippet RiskCell(props: { row: Patient })}\n <span class={`hc-risk hc-risk-${props.row.risk}`}>\n <span class=\"hc-risk-bar\">\n <span class=\"hc-risk-fill\" style={`width: ${props.row.riskScore}%`}></span>\n </span>\n <span class=\"hc-risk-num tabular-nums\">{props.row.riskScore}</span>\n </span>\n{/snippet}\n\n{#snippet CodeStatusCell(props: { row: Patient })}\n <span class={`hc-code hc-code-${props.row.codeStatus}`}>\n {props.row.codeStatus.replace(/_/g, ' ').toUpperCase()}\n </span>\n{/snippet}\n\n{#snippet VitalsCell(props: { row: Patient })}\n {@const pathD = sparkPath(props.row.hrTrend, 90, 24)}\n <span class=\"hc-vitals\">\n <svg class=\"hc-spark\" viewBox=\"0 0 90 24\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <path d={pathD} fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\" />\n </svg>\n <span class=\"hc-vital-num tabular-nums\">HR {props.row.hr}</span>\n <span class=\"hc-vital-sub\">SpO₂ {props.row.spo2}% · BP {props.row.sbp}</span>\n </span>\n{/snippet}\n\n{#snippet AllergiesCell(props: { row: Patient })}\n {#if props.row.allergies.length === 0}\n <span class=\"hc-no-allergies\">NKDA</span>\n {:else}\n <span class=\"hc-allergy-list\">\n {#each props.row.allergies as a (a)}<span class=\"hc-allergy\">{a}</span>{/each}\n </span>\n {/if}\n{/snippet}\n\n{#snippet PlainCell(props: { row: Patient; field: keyof Patient })}\n <span>{props.row[props.field]}</span>\n{/snippet}\n\n<!-- ───────────────────── LAYOUT ───────────────────── -->\n\n<section class=\"hc-shell flex flex-col flex-1 min-h-0 gap-3\">\n <!-- KPI strip -->\n <div class=\"hc-kpi-strip\">\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Census</div>\n <div class=\"hc-kpi-value tabular-nums\">{kpis.total}</div>\n <div class=\"hc-kpi-foot\">patients on the unit</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Critical</div>\n <div class=\"hc-kpi-value tabular-nums hc-down\">{kpis.critical}</div>\n <div class=\"hc-kpi-foot\">{kpis.high} high · LOS avg {kpis.avgLos}d</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">DNR</div>\n <div class=\"hc-kpi-value tabular-nums\">{kpis.dnr}</div>\n <div class=\"hc-kpi-foot\">code-status reviews queued</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Avg risk score</div>\n <div class={`hc-kpi-value tabular-nums ${kpis.avgRisk >= 60 ? 'hc-down' : ''}`}>{kpis.avgRisk}</div>\n <div class=\"hc-kpi-foot\">0–100, weighted</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Role</div>\n <div class={`hc-kpi-value hc-role hc-role-${role}`}>{role}</div>\n <div class=\"hc-kpi-foot\">switch below to compare</div>\n </div>\n </div>\n\n <!-- Toolbar -->\n <div class=\"hc-toolbar\">\n <span class=\"hc-role-row\">\n <span class=\"hc-throttle-label\">Acting as</span>\n {#each [{id: 'viewer', label: 'Viewer'}, {id: 'nurse', label: 'Nurse'}, {id: 'physician', label: 'Physician'}, {id: 'admin', label: 'Admin'}] as r (r.id)}\n <button\n type=\"button\"\n class={`hc-role-btn hc-role-${r.id}`}\n class:hc-role-active={role === r.id}\n onclick={() => (role = r.id as Role)}\n >{r.label}</button>\n {/each}\n </span>\n <div class=\"hc-toolbar-spacer\"></div>\n <span class=\"hc-hint\">Double-click a cell to edit. The role decides which cells unlock.</span>\n </div>\n\n <!-- Grid -->\n <div class=\"flex-1 min-h-0\">\n <SvGrid responsive={true}\n columnResize\n data={patients}\n columns={[\n { field: 'mrn', header: 'MRN / pt.', width: 150, editable: false,\n cell: (ctx) => renderSnippet(MrnCell, { row: ctx.row.original }) },\n { field: 'age', header: 'Age', editorType: 'number', width: 80, editable: false,\n cell: (ctx) => renderSnippet(AgeSexCell, { row: ctx.row.original }) },\n { field: 'room', header: 'Location', width: 110,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('room'),\n cell: (ctx) => renderSnippet(LocationCell, { row: ctx.row.original }) },\n { field: 'diagnosis', header: 'Diagnosis', width: 220,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('diagnosis'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'diagnosis' }) },\n { field: 'riskScore', header: 'Risk', editorType: 'number', width: 160, editable: false,\n cell: (ctx) => renderSnippet(RiskCell, { row: ctx.row.original }) },\n { field: 'codeStatus', header: 'Code', width: 130,\n editorType: 'list',\n editorOptions: CODE_STATUSES as unknown as ReadonlyArray<string>,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('codeStatus'),\n cell: (ctx) => renderSnippet(CodeStatusCell, { row: ctx.row.original }) },\n { field: 'hr', header: 'Vitals (last 30 hr)', editorType: 'number', width: 200, editable: false,\n cell: (ctx) => renderSnippet(VitalsCell, { row: ctx.row.original }) },\n { field: 'allergies', header: 'Allergies', width: 200,\n editorType: 'chips',\n editorOptions: ALLERGY_POOL as unknown as ReadonlyArray<string>,\n editorMultiple: true,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('allergies'),\n cell: (ctx) => renderSnippet(AllergiesCell, { row: ctx.row.original }) },\n { field: 'attending', header: 'Attending', width: 140,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('attending'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'attending' }) },\n { field: 'primaryNurse', header: 'Primary RN', width: 110,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('primaryNurse'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'primaryNurse' }) },\n { field: 'los', header: 'LOS', editorType: 'number', width: 70,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('los'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'los' }) },\n { field: 'notes', header: 'Notes', editorType: 'text', width: 200,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('notes') },\n ] satisfies ColumnDef<typeof features, Patient>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n enableInlineEditing={role !== 'viewer'}\n enableCellSelection={true}\n rowHeight={48}\n containerHeight=\"100%\"\n fitColumns={false}\n onCellValueChange={onCellValueChange}\n />\n </div>\n</section>\n\n<style>\n /* ─── KPI strip ──────────────────────────────────────────────── */\n .hc-kpi-strip {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 10px;\n flex-shrink: 0;\n }\n .hc-kpi {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #ffffff);\n border-radius: 10px;\n padding: 12px 14px;\n }\n .hc-kpi-label {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: var(--sg-muted, #64748b);\n margin-bottom: 6px;\n }\n .hc-kpi-value {\n font-size: 22px;\n font-weight: 700;\n line-height: 1.1;\n letter-spacing: -0.01em;\n text-transform: capitalize;\n }\n .hc-kpi-foot {\n margin-top: 6px;\n font-size: 11px;\n color: var(--sg-muted, #64748b);\n }\n .hc-down { color: #dc2626; }\n :global([data-theme='dark']) .hc-down { color: #f87171; }\n .hc-role {\n font-size: 16px;\n }\n\n /* ─── Toolbar ────────────────────────────────────────────────── */\n .hc-toolbar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n }\n .hc-toolbar-spacer { flex: 1 1 auto; }\n .hc-role-row { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; }\n .hc-throttle-label {\n font-size: 11.5px;\n color: var(--sg-muted, #64748b);\n margin-right: 4px;\n }\n .hc-role-btn {\n padding: 4px 12px;\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 999px;\n font-size: 12px;\n cursor: pointer;\n }\n .hc-role-btn:hover { background: var(--sg-header-bg, #f1f5f9); }\n .hc-role-active {\n border-color: transparent;\n box-shadow: 0 0 0 2px var(--sg-accent, #2563eb) inset;\n font-weight: 600;\n }\n .hc-role-viewer { color: var(--sg-muted, #475569); }\n .hc-role-nurse { color: #166534; }\n .hc-role-physician { color: #1d4ed8; }\n .hc-role-admin { color: #b91c1c; }\n :global([data-theme='dark']) .hc-role-nurse { color: #4ade80; }\n :global([data-theme='dark']) .hc-role-physician { color: #93c5fd; }\n :global([data-theme='dark']) .hc-role-admin { color: #f87171; }\n .hc-hint { font-size: 11.5px; color: var(--sg-muted, #64748b); }\n\n /* ─── Cells ──────────────────────────────────────────────────── */\n :global(.mono) {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12px;\n letter-spacing: 0.02em;\n }\n\n :global(.hc-mrn-wrap) {\n display: inline-flex;\n flex-direction: column;\n gap: 1px;\n }\n :global(.hc-mrn-init) {\n font-size: 10.5px;\n color: var(--sg-muted, #64748b);\n letter-spacing: 0.05em;\n }\n\n :global(.hc-age) {\n font-variant-numeric: tabular-nums;\n font-weight: 600;\n }\n :global(.hc-sex) {\n font-size: 10px;\n color: var(--sg-muted, #64748b);\n margin-left: 3px;\n }\n\n :global(.hc-location) {\n display: inline-flex;\n align-items: baseline;\n gap: 3px;\n }\n :global(.hc-room) { font-weight: 600; font-family: ui-monospace, Menlo, monospace; }\n :global(.hc-bed) { font-size: 11px; color: var(--sg-muted, #64748b); }\n\n :global(.hc-risk) {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n :global(.hc-risk-bar) {\n position: relative;\n width: 70px;\n height: 6px;\n background: var(--sg-border, #e2e8f0);\n border-radius: 3px;\n overflow: hidden;\n }\n :global(.hc-risk-fill) {\n position: absolute; inset: 0 auto 0 0;\n background: currentColor;\n }\n :global(.hc-risk-num) {\n font-weight: 700;\n min-width: 26px;\n }\n :global(.hc-risk-low) { color: #16a34a; }\n :global(.hc-risk-moderate) { color: #ca8a04; }\n :global(.hc-risk-high) { color: #ea580c; }\n :global(.hc-risk-critical) { color: #dc2626; }\n\n :global(.hc-code) {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 4px;\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.04em;\n }\n :global(.hc-code-full_code) { background: #dcfce7; color: #166534; }\n :global(.hc-code-dnr) { background: #fee2e2; color: #b91c1c; }\n :global(.hc-code-dni) { background: #fef3c7; color: #92400e; }\n :global(.hc-code-comfort_only) { background: var(--sg-bg-subtle, var(--sg-header-bg, #e2e8f0)); color: var(--sg-muted, #475569); }\n :global([data-theme='dark'] .hc-code-full_code) { background: rgba(34,197,94,.18); color: #4ade80; }\n :global([data-theme='dark'] .hc-code-dnr) { background: rgba(239,68,68,.18); color: #f87171; }\n :global([data-theme='dark'] .hc-code-dni) { background: rgba(245,158,11,.18); color: #fbbf24; }\n\n :global(.hc-vitals) {\n display: inline-flex;\n flex-direction: column;\n gap: 1px;\n color: #2563eb;\n line-height: 1.2;\n }\n :global([data-theme='dark'] .hc-vitals) { color: #60a5fa; }\n :global(.hc-spark) { width: 90px; height: 24px; }\n :global(.hc-vital-num) {\n color: var(--sg-fg, #1e293b);\n font-weight: 600;\n font-size: 12px;\n }\n :global(.hc-vital-sub) {\n color: var(--sg-muted, #64748b);\n font-size: 10.5px;\n }\n\n :global(.hc-no-allergies) {\n color: var(--sg-muted, #64748b);\n font-size: 11px;\n font-style: italic;\n }\n :global(.hc-allergy-list) {\n display: inline-flex;\n flex-wrap: wrap;\n gap: 3px;\n }\n :global(.hc-allergy) {\n background: #fee2e2;\n color: #b91c1c;\n padding: 1px 6px;\n border-radius: 999px;\n font-size: 10.5px;\n font-weight: 600;\n }\n :global([data-theme='dark'] .hc-allergy) { background: rgba(239,68,68,.18); color: #f87171; }\n</style>\n"
2368
+ "source": "<!-- Documented in: docs/help/recipes.md -->\n<script lang=\"ts\">\n /**\n * 41. Healthcare EMR - inpatient board\n * ------------------------------------\n * An ICU / inpatient list as it would appear to a clinician. Built\n * to show the procurement team's healthcare buyer two things at\n * once:\n *\n * 1. **The grid renders rich clinical cells.** Vitals sparkline,\n * color-graded risk score, allergy chip cluster, code-status\n * pill, primary-nurse avatar - all in stock SvGrid cell\n * snippets, no plugin layer.\n *\n * 2. **Role-based access actually works.** The role switcher\n * drives the cell-level `editable: (ctx) => boolean` predicate\n * the grid added in demo 35. Nurses can edit notes; physicians\n * can move code status and orders; admins also see discharge\n * planning fields. Try Viewer to see everything lock.\n *\n * No PHI: every name, MRN, allergy, and diagnosis is synthetic\n * and deterministically seeded. The structure is what counts.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n renderSnippet,\n type ColumnDef,\n type CellContext,\n } from '@svgrid/grid'\n\n // ---- Domain ---------------------------------------------------------\n\n type CodeStatus = 'full_code' | 'dnr' | 'dni' | 'comfort_only'\n type Risk = 'low' | 'moderate' | 'high' | 'critical'\n type Role = 'viewer' | 'nurse' | 'physician' | 'admin'\n\n type Allergy = string\n type Patient = {\n mrn: string\n initials: string\n age: number\n sex: 'F' | 'M'\n room: string\n bed: string\n los: number\n diagnosis: string\n risk: Risk\n riskScore: number // 0–100\n codeStatus: CodeStatus\n allergies: Allergy[]\n attending: string\n primaryNurse: string\n notes: string\n /** 30-point trailing vitals (heart rate). */\n hrTrend: number[]\n /** Latest values for the trend tile. */\n hr: number\n spo2: number\n sbp: number // systolic BP\n }\n\n // ---- Seed -----------------------------------------------------------\n\n const FIRST_INITIALS = ['A.M.', 'J.K.', 'R.S.', 'M.O.', 'L.P.', 'D.B.', 'C.T.', 'N.E.', 'S.H.', 'K.Y.']\n const DIAGNOSES = [\n 'Sepsis, unspecified', 'ARDS w/ mechanical vent', 'Post-op cabg',\n 'Septic shock', 'STEMI, anterior', 'Acute pancreatitis',\n 'Cerebrovascular accident', 'Pneumonia, MRSA', 'GI bleed, lower',\n 'DKA, T1DM', 'Pulmonary embolism', 'Renal failure, AKI',\n ]\n const ALLERGY_POOL = ['Penicillin', 'Latex', 'Sulfa', 'Iodine', 'Codeine', 'Aspirin', 'NSAIDs', 'Shellfish', 'Eggs']\n const ATTENDINGS = ['Dr. Park', 'Dr. Singh', 'Dr. Cohen', 'Dr. Nakamura', 'Dr. Mehta', 'Dr. Diaz']\n const NURSES = ['Tran', 'Olsen', 'Wu', 'Khan', 'Diaz', 'Park', 'Nair', 'Silva']\n const CODE_STATUSES: readonly CodeStatus[] = ['full_code', 'dnr', 'dni', 'comfort_only']\n\n let prng = 0xC0CA0DE5\n function rnd(): number {\n prng = (prng * 1664525 + 1013904223) >>> 0\n return prng / 0xFFFFFFFF\n }\n function pick<T>(arr: readonly T[]): T { return arr[Math.floor(rnd() * arr.length)]! }\n\n function seedPatient(i: number): Patient {\n const initials = pick(FIRST_INITIALS)\n const age = 18 + Math.floor(rnd() * 80)\n const sex: 'F' | 'M' = rnd() < 0.5 ? 'F' : 'M'\n const riskScore = Math.floor(rnd() * 100)\n const risk: Risk =\n riskScore >= 80 ? 'critical' :\n riskScore >= 60 ? 'high' :\n riskScore >= 30 ? 'moderate' : 'low'\n const baseHr = 60 + Math.floor(rnd() * 70)\n const hrTrend: number[] = []\n let hr = baseHr\n for (let k = 0; k < 30; k += 1) {\n hr = Math.max(45, Math.min(160, hr + (rnd() - 0.5) * 8))\n hrTrend.push(Math.round(hr))\n }\n const allergyCount = Math.floor(rnd() * 3)\n const allergies: Allergy[] = []\n while (allergies.length < allergyCount) {\n const a = pick(ALLERGY_POOL)\n if (!allergies.includes(a)) allergies.push(a)\n }\n return {\n mrn: `M${(100_000 + i).toString().slice(-6)}`,\n initials,\n age, sex,\n room: `${pick(['SI', 'CC', 'MS'])}-${100 + Math.floor(rnd() * 30)}`,\n bed: pick(['A', 'B']),\n los: 1 + Math.floor(rnd() * 14),\n diagnosis: pick(DIAGNOSES),\n risk,\n riskScore,\n codeStatus: pick(CODE_STATUSES),\n allergies,\n attending: pick(ATTENDINGS),\n primaryNurse: pick(NURSES),\n notes: '',\n hrTrend,\n hr: hrTrend[hrTrend.length - 1]!,\n spo2: 88 + Math.floor(rnd() * 11),\n sbp: 90 + Math.floor(rnd() * 60),\n }\n }\n function seedRoster(n: number): Patient[] {\n const out: Patient[] = []\n for (let i = 0; i < n; i += 1) out.push(seedPatient(i))\n return out\n }\n\n // ---- State ---------------------------------------------------------\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let patients = $state<Patient[]>(seedRoster(28))\n let role = $state<Role>('physician')\n\n // ---- Role permission rules -----------------------------------------\n\n const NURSE_EDITABLE = new Set(['notes', 'primaryNurse'])\n const PHYS_EDITABLE = new Set(['notes', 'primaryNurse', 'attending', 'codeStatus', 'diagnosis', 'allergies'])\n const ADMIN_EXTRA = new Set(['room', 'bed', 'los'])\n function canEditColumn(columnId: string): boolean {\n if (role === 'viewer') return false\n if (role === 'admin') return PHYS_EDITABLE.has(columnId) || ADMIN_EXTRA.has(columnId)\n if (role === 'physician') return PHYS_EDITABLE.has(columnId)\n return NURSE_EDITABLE.has(columnId)\n }\n function canSeeColumn(columnId: string): boolean {\n if (columnId === 'discharge') return role === 'admin'\n return true\n }\n\n function onCellValueChange(args: { rowIndex: number; columnId: string; newValue: unknown }): void {\n const patient = patients[args.rowIndex]\n if (!patient) return\n if (!canEditColumn(args.columnId)) return\n const next = patients.slice()\n next[args.rowIndex] = { ...patient, [args.columnId]: args.newValue } as Patient\n patients = next\n }\n\n // ---- KPI derivations ----------------------------------------------\n\n const kpis = $derived.by(() => {\n const total = patients.length\n const critical = patients.filter((p) => p.risk === 'critical').length\n const high = patients.filter((p) => p.risk === 'high').length\n const dnr = patients.filter((p) => p.codeStatus === 'dnr').length\n const avgRisk = total ? Math.round(patients.reduce((s, p) => s + p.riskScore, 0) / total) : 0\n const avgLos = total ? +(patients.reduce((s, p) => s + p.los, 0) / total).toFixed(1) : 0\n return { total, critical, high, dnr, avgRisk, avgLos }\n })\n\n // ---- Sparkline helpers --------------------------------------------\n\n function sparkPath(history: number[], w: number, h: number): string {\n const min = Math.min(...history)\n const max = Math.max(...history)\n const range = Math.max(1e-6, max - min)\n const step = w / (history.length - 1)\n let d = ''\n for (let i = 0; i < history.length; i += 1) {\n const x = Math.round(i * step * 100) / 100\n const y = Math.round((h - ((history[i]! - min) / range) * h) * 100) / 100\n d += `${i === 0 ? 'M' : 'L'}${x} ${y} `\n }\n return d.trim()\n }\n</script>\n\n<!-- ───────────────────── CELL SNIPPETS ───────────────────── -->\n\n{#snippet MrnCell(props: { row: Patient })}\n <span class=\"hc-mrn-wrap\">\n <span class=\"mono\">{props.row.mrn}</span>\n <span class=\"hc-mrn-init\">{props.row.initials}</span>\n </span>\n{/snippet}\n\n{#snippet AgeSexCell(props: { row: Patient })}\n <span class=\"hc-age\">{props.row.age}<span class=\"hc-sex\">{props.row.sex}</span></span>\n{/snippet}\n\n{#snippet LocationCell(props: { row: Patient })}\n <span class=\"hc-location\">\n <span class=\"hc-room\">{props.row.room}</span>\n <span class=\"hc-bed\">{props.row.bed}</span>\n </span>\n{/snippet}\n\n{#snippet RiskCell(props: { row: Patient })}\n <span class={`hc-risk hc-risk-${props.row.risk}`}>\n <span class=\"hc-risk-bar\">\n <span class=\"hc-risk-fill\" style={`width: ${props.row.riskScore}%`}></span>\n </span>\n <span class=\"hc-risk-num tabular-nums\">{props.row.riskScore}</span>\n </span>\n{/snippet}\n\n{#snippet CodeStatusCell(props: { row: Patient })}\n <span class={`hc-code hc-code-${props.row.codeStatus}`}>\n {props.row.codeStatus.replace(/_/g, ' ').toUpperCase()}\n </span>\n{/snippet}\n\n{#snippet VitalsCell(props: { row: Patient })}\n {@const pathD = sparkPath(props.row.hrTrend, 90, 24)}\n <span class=\"hc-vitals\">\n <svg class=\"hc-spark\" viewBox=\"0 0 90 24\" preserveAspectRatio=\"none\" aria-hidden=\"true\">\n <path d={pathD} fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.4\" />\n </svg>\n <span class=\"hc-vital-num tabular-nums\">HR {props.row.hr}</span>\n <span class=\"hc-vital-sub\">SpO₂ {props.row.spo2}% · BP {props.row.sbp}</span>\n </span>\n{/snippet}\n\n{#snippet AllergiesCell(props: { row: Patient })}\n {#if props.row.allergies.length === 0}\n <span class=\"hc-no-allergies\">NKDA</span>\n {:else}\n <span class=\"hc-allergy-list\">\n {#each props.row.allergies as a (a)}<span class=\"hc-allergy\">{a}</span>{/each}\n </span>\n {/if}\n{/snippet}\n\n{#snippet PlainCell(props: { row: Patient; field: keyof Patient })}\n <span>{props.row[props.field]}</span>\n{/snippet}\n\n<!-- ───────────────────── LAYOUT ───────────────────── -->\n\n<section class=\"hc-shell flex flex-col flex-1 min-h-0 gap-3\">\n <!-- KPI strip -->\n <div class=\"hc-kpi-strip\">\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Census</div>\n <div class=\"hc-kpi-value tabular-nums\">{kpis.total}</div>\n <div class=\"hc-kpi-foot\">patients on the unit</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Critical</div>\n <div class=\"hc-kpi-value tabular-nums hc-down\">{kpis.critical}</div>\n <div class=\"hc-kpi-foot\">{kpis.high} high · LOS avg {kpis.avgLos}d</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">DNR</div>\n <div class=\"hc-kpi-value tabular-nums\">{kpis.dnr}</div>\n <div class=\"hc-kpi-foot\">code-status reviews queued</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Avg risk score</div>\n <div class={`hc-kpi-value tabular-nums ${kpis.avgRisk >= 60 ? 'hc-down' : ''}`}>{kpis.avgRisk}</div>\n <div class=\"hc-kpi-foot\">0–100, weighted</div>\n </div>\n <div class=\"hc-kpi\">\n <div class=\"hc-kpi-label\">Role</div>\n <div class={`hc-kpi-value hc-role hc-role-${role}`}>{role}</div>\n <div class=\"hc-kpi-foot\">switch below to compare</div>\n </div>\n </div>\n\n <!-- Toolbar -->\n <div class=\"hc-toolbar\">\n <span class=\"hc-role-row\">\n <span class=\"hc-throttle-label\">Acting as</span>\n {#each [{id: 'viewer', label: 'Viewer'}, {id: 'nurse', label: 'Nurse'}, {id: 'physician', label: 'Physician'}, {id: 'admin', label: 'Admin'}] as r (r.id)}\n <button\n type=\"button\"\n class={`hc-role-btn hc-role-${r.id}`}\n class:hc-role-active={role === r.id}\n onclick={() => (role = r.id as Role)}\n >{r.label}</button>\n {/each}\n </span>\n <div class=\"hc-toolbar-spacer\"></div>\n <span class=\"hc-hint\">Double-click a cell to edit. The role decides which cells unlock.</span>\n </div>\n\n <!-- Grid -->\n <div class=\"flex-1 min-h-0\">\n <SvGrid responsive={true}\n columnResize\n data={patients}\n columns={[\n { field: 'mrn', header: 'MRN / pt.', width: 150, editable: false,\n cell: (ctx) => renderSnippet(MrnCell, { row: ctx.row.original }) },\n { field: 'age', header: 'Age', editorType: 'number', width: 80, editable: false,\n cell: (ctx) => renderSnippet(AgeSexCell, { row: ctx.row.original }) },\n { field: 'room', header: 'Location', width: 110,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('room'),\n cell: (ctx) => renderSnippet(LocationCell, { row: ctx.row.original }) },\n { field: 'diagnosis', header: 'Diagnosis', width: 220,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('diagnosis'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'diagnosis' }) },\n { field: 'riskScore', header: 'Risk', editorType: 'number', width: 160, editable: false,\n cell: (ctx) => renderSnippet(RiskCell, { row: ctx.row.original }) },\n { field: 'codeStatus', header: 'Code', width: 130,\n editorType: 'list',\n editorOptions: CODE_STATUSES as unknown as ReadonlyArray<string>,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('codeStatus'),\n cell: (ctx) => renderSnippet(CodeStatusCell, { row: ctx.row.original }) },\n { field: 'hr', header: 'Vitals (last 30 hr)', editorType: 'number', width: 200, editable: false,\n cell: (ctx) => renderSnippet(VitalsCell, { row: ctx.row.original }) },\n { field: 'allergies', header: 'Allergies', width: 200,\n editorType: 'chips',\n editorOptions: ALLERGY_POOL as unknown as ReadonlyArray<string>,\n editorMultiple: true,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('allergies'),\n cell: (ctx) => renderSnippet(AllergiesCell, { row: ctx.row.original }) },\n { field: 'attending', header: 'Attending', width: 140,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('attending'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'attending' }) },\n { field: 'primaryNurse', header: 'Primary RN', width: 110,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('primaryNurse'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'primaryNurse' }) },\n { field: 'los', header: 'LOS', editorType: 'number', width: 70,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('los'),\n cell: (ctx) => renderSnippet(PlainCell, { row: ctx.row.original, field: 'los' }) },\n { field: 'notes', header: 'Notes', editorType: 'text', width: 200,\n editable: (_ctx: CellContext<Patient>) => canEditColumn('notes') },\n ] satisfies ColumnDef<typeof features, Patient>[]}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n enableInlineEditing={role !== 'viewer'}\n enableCellSelection={true}\n rowHeight={54}\n containerHeight=\"100%\"\n fitColumns={false}\n onCellValueChange={onCellValueChange}\n />\n </div>\n</section>\n\n<style>\n /* ─── KPI strip ──────────────────────────────────────────────── */\n .hc-kpi-strip {\n display: grid;\n grid-template-columns: repeat(5, minmax(0, 1fr));\n gap: 10px;\n flex-shrink: 0;\n }\n .hc-kpi {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #ffffff);\n border-radius: 10px;\n padding: 12px 14px;\n }\n .hc-kpi-label {\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n color: var(--sg-muted, #64748b);\n margin-bottom: 6px;\n }\n .hc-kpi-value {\n font-size: 22px;\n font-weight: 700;\n line-height: 1.1;\n letter-spacing: -0.01em;\n text-transform: capitalize;\n }\n .hc-kpi-foot {\n margin-top: 6px;\n font-size: 11px;\n color: var(--sg-muted, #64748b);\n }\n .hc-down { color: #dc2626; }\n :global([data-theme='dark']) .hc-down { color: #f87171; }\n .hc-role {\n font-size: 16px;\n }\n\n /* ─── Toolbar ────────────────────────────────────────────────── */\n .hc-toolbar {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 8px;\n flex-shrink: 0;\n }\n .hc-toolbar-spacer { flex: 1 1 auto; }\n .hc-role-row { display: inline-flex; flex-wrap: wrap; gap: 6px; align-items: center; }\n .hc-throttle-label {\n font-size: 11.5px;\n color: var(--sg-muted, #64748b);\n margin-right: 4px;\n }\n .hc-role-btn {\n padding: 4px 12px;\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #ffffff);\n color: var(--sg-fg, #1e293b);\n border-radius: 999px;\n font-size: 12px;\n cursor: pointer;\n }\n .hc-role-btn:hover { background: var(--sg-header-bg, #f1f5f9); }\n .hc-role-active {\n border-color: transparent;\n box-shadow: 0 0 0 2px var(--sg-accent, #2563eb) inset;\n font-weight: 600;\n }\n .hc-role-viewer { color: var(--sg-muted, #475569); }\n .hc-role-nurse { color: #166534; }\n .hc-role-physician { color: #1d4ed8; }\n .hc-role-admin { color: #b91c1c; }\n :global([data-theme='dark']) .hc-role-nurse { color: #4ade80; }\n :global([data-theme='dark']) .hc-role-physician { color: #93c5fd; }\n :global([data-theme='dark']) .hc-role-admin { color: #f87171; }\n .hc-hint { font-size: 11.5px; color: var(--sg-muted, #64748b); }\n\n /* ─── Cells ──────────────────────────────────────────────────── */\n :global(.mono) {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12px;\n letter-spacing: 0.02em;\n }\n\n :global(.hc-mrn-wrap) {\n display: inline-flex;\n flex-direction: column;\n gap: 1px;\n }\n :global(.hc-mrn-init) {\n font-size: 10.5px;\n color: var(--sg-muted, #64748b);\n letter-spacing: 0.05em;\n }\n\n :global(.hc-age) {\n font-variant-numeric: tabular-nums;\n font-weight: 600;\n }\n :global(.hc-sex) {\n font-size: 10px;\n color: var(--sg-muted, #64748b);\n margin-left: 3px;\n }\n\n :global(.hc-location) {\n display: inline-flex;\n align-items: baseline;\n gap: 3px;\n }\n :global(.hc-room) { font-weight: 600; font-family: ui-monospace, Menlo, monospace; }\n :global(.hc-bed) { font-size: 11px; color: var(--sg-muted, #64748b); }\n\n :global(.hc-risk) {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n }\n :global(.hc-risk-bar) {\n position: relative;\n width: 70px;\n height: 6px;\n background: var(--sg-border, #e2e8f0);\n border-radius: 3px;\n overflow: hidden;\n }\n :global(.hc-risk-fill) {\n position: absolute; inset: 0 auto 0 0;\n background: currentColor;\n }\n :global(.hc-risk-num) {\n font-weight: 700;\n min-width: 26px;\n }\n :global(.hc-risk-low) { color: #16a34a; }\n :global(.hc-risk-moderate) { color: #ca8a04; }\n :global(.hc-risk-high) { color: #ea580c; }\n :global(.hc-risk-critical) { color: #dc2626; }\n\n :global(.hc-code) {\n display: inline-block;\n padding: 2px 8px;\n border-radius: 4px;\n font-size: 10.5px;\n font-weight: 700;\n letter-spacing: 0.04em;\n }\n :global(.hc-code-full_code) { background: #dcfce7; color: #166534; }\n :global(.hc-code-dnr) { background: #fee2e2; color: #b91c1c; }\n :global(.hc-code-dni) { background: #fef3c7; color: #92400e; }\n :global(.hc-code-comfort_only) { background: var(--sg-bg-subtle, var(--sg-header-bg, #e2e8f0)); color: var(--sg-muted, #475569); }\n :global([data-theme='dark'] .hc-code-full_code) { background: rgba(34,197,94,.18); color: #4ade80; }\n :global([data-theme='dark'] .hc-code-dnr) { background: rgba(239,68,68,.18); color: #f87171; }\n :global([data-theme='dark'] .hc-code-dni) { background: rgba(245,158,11,.18); color: #fbbf24; }\n\n :global(.hc-vitals) {\n display: inline-flex;\n flex-direction: column;\n gap: 1px;\n color: #2563eb;\n line-height: 1.2;\n }\n :global([data-theme='dark'] .hc-vitals) { color: #60a5fa; }\n :global(.hc-spark) { width: 90px; height: 24px; }\n :global(.hc-vital-num) {\n color: var(--sg-fg, #1e293b);\n font-weight: 600;\n font-size: 12px;\n }\n :global(.hc-vital-sub) {\n color: var(--sg-muted, #64748b);\n font-size: 10.5px;\n }\n\n :global(.hc-no-allergies) {\n color: var(--sg-muted, #64748b);\n font-size: 11px;\n font-style: italic;\n }\n :global(.hc-allergy-list) {\n display: inline-flex;\n flex-wrap: wrap;\n gap: 3px;\n }\n :global(.hc-allergy) {\n background: #fee2e2;\n color: #b91c1c;\n padding: 1px 6px;\n border-radius: 999px;\n font-size: 10.5px;\n font-weight: 600;\n }\n :global([data-theme='dark'] .hc-allergy) { background: rgba(239,68,68,.18); color: #f87171; }\n</style>\n"
2369
2369
  },
2370
2370
  {
2371
2371
  "id": "410-typography-display",
@@ -3055,6 +3055,14 @@ export const examples = [
3055
3055
  "blurb": "502. Ops workspace (app template) An on-call workspace on SvDockManager. Panes: a live service grid, p50 / p95 / p99 latency and error charts for the selected service, an",
3056
3056
  "source": "<script lang=\"ts\">\n /**\n * 502. Ops workspace (app template)\n * ---------------------------------\n * An on-call workspace on SvDockManager. Panes: a live service grid,\n * p50 / p95 / p99 latency and error charts for the selected service, an\n * incident board (Board or Table over the same SvGrid), deploys, on-call and\n * an alert feed. Every pane drags, tabs, floats and auto-hides, and the\n * arrangement saves to localStorage.\n *\n * The panes run one incident loop, the way a real on-call tool does:\n * 1. A deploy goes out (progress, then live) and the service degrades.\n * 2. An alert rule fires: the row tints, the feed logs it, an incident\n * opens in Triage and the team's primary is paged.\n * 3. The primary acknowledges; the incident's timeline records each step.\n * 4. Rolling back the deploy recovers the service and moves the incident\n * to Mitigated; resolve it from the incident drawer.\n * A scripted bad deploy runs the loop a few seconds after load; Inject\n * failure runs it again on the selected service.\n *\n * Dock layout, grid, charts, drawer: @svgrid/grid.\n * Alert rules, Kanban board renderer, export: @svgrid/enterprise.\n */\n import { untrack } from 'svelte'\n import {\n SvGrid,\n SvDockManager,\n SvChart,\n SvDrawer,\n SvMenu,\n SvAvatar,\n SvToaster,\n toast,\n renderSnippet,\n dockGroup,\n dockTabs,\n dockPane,\n type ColumnDef,\n type ChartSpec,\n type DockManagerState,\n type MenuItem,\n type SvGridApi,\n type BoardCardMoveEvent,\n } from '@svgrid/grid'\n import type { ConditionalFormat } from '@svgrid/grid/format'\n import {\n SvGridAlerts,\n alertStore,\n enableAlerts,\n enableBoardView,\n installEnterprise,\n type AlertEvent,\n type AlertRule,\n type EnterpriseGridApi,\n type ExportColumn,\n type ExportFormat,\n type ExprColumn,\n } from '@svgrid/enterprise'\n\n enableAlerts()\n enableBoardView()\n\n // ---- Domain ---------------------------------------------------------------\n type Status = 'healthy' | 'degraded' | 'down'\n type Service = {\n id: string\n name: string\n team: string\n tier: 'P0' | 'P1' | 'P2'\n region: string\n rps: number\n p95: number\n errorPct: number\n cpu: number\n version: string\n status: Status\n p95History: number[]\n }\n type Baseline = { p95: number; rps: number; err: number; cpu: number }\n type IncStatus = 'triage' | 'investigating' | 'mitigated' | 'resolved'\n type Severity = 'SEV1' | 'SEV2' | 'SEV3'\n type TimelineEntry = { at: number; who: string; text: string; kind: 'open' | 'status' | 'sev' | 'page' | 'ack' | 'note' | 'deploy' }\n type Incident = {\n id: string\n title: string\n service: string\n severity: Severity\n status: IncStatus\n commander: string\n opened: string\n source: 'alert' | 'manual'\n timeline: TimelineEntry[]\n }\n type Deploy = {\n id: number\n service: string\n version: string\n from: string\n author: string\n at: number\n sha: string\n message: string\n kind: 'deploy' | 'rollback'\n state: 'deploying' | 'live'\n progress: number\n rolledBack?: boolean\n /** A bad deploy turns its service sour once it is live. */\n chaos?: Chaos['kind']\n }\n type Chaos = { kind: 'latency' | 'errors'; ticks: number }\n\n const SLO_P95 = 800\n const ERROR_LIMIT = 5\n const HISTORY = 40\n const TICK_MS = 1500\n const ME = 'Robin Hale'\n\n const ROTA: Record<string, { primary: string; secondary: string; until: number }> = {\n Payments: { primary: 'Priya Nair', secondary: 'Leo Marsh', until: 18 },\n Platform: { primary: 'Owen Bradley', secondary: 'Ana Ruiz', until: 20 },\n Growth: { primary: 'Grace Kim', secondary: 'Tom Haas', until: 17 },\n Search: { primary: 'Sam Ortiz', secondary: 'Ivy Okafor', until: 19 },\n Data: { primary: 'Mia Chen', secondary: 'Lena Fischer', until: 18 },\n }\n const PEOPLE = [ME, ...Object.values(ROTA).flatMap((r) => [r.primary, r.secondary])]\n\n const SEED: Array<Omit<Service, 'rps' | 'p95' | 'errorPct' | 'cpu' | 'status' | 'p95History'> & Baseline> = [\n { id: 'checkout-api', name: 'checkout-api', team: 'Payments', tier: 'P0', region: 'us-east', version: 'v2.13.4', p95: 310, rps: 1840, err: 0.4, cpu: 52 },\n { id: 'payments-gw', name: 'payments-gw', team: 'Payments', tier: 'P0', region: 'us-east', version: 'v5.2.0', p95: 420, rps: 960, err: 0.6, cpu: 47 },\n { id: 'auth', name: 'auth', team: 'Platform', tier: 'P0', region: 'global', version: 'v8.0.3', p95: 95, rps: 5200, err: 0.1, cpu: 38 },\n { id: 'edge-proxy', name: 'edge-proxy', team: 'Platform', tier: 'P0', region: 'global', version: 'v1.31.0', p95: 42, rps: 14800, err: 0.05, cpu: 61 },\n { id: 'catalog', name: 'catalog', team: 'Growth', tier: 'P1', region: 'eu-west', version: 'v3.9.1', p95: 180, rps: 3100, err: 0.3, cpu: 44 },\n { id: 'search', name: 'search', team: 'Search', tier: 'P1', region: 'eu-west', version: 'v12.4.2', p95: 260, rps: 2650, err: 0.5, cpu: 66 },\n { id: 'recommendations', name: 'recommendations', team: 'Growth', tier: 'P2', region: 'eu-west', version: 'v0.18.7', p95: 540, rps: 720, err: 0.8, cpu: 71 },\n { id: 'notifications', name: 'notifications', team: 'Platform', tier: 'P1', region: 'us-west', version: 'v4.4.0', p95: 150, rps: 1300, err: 0.2, cpu: 29 },\n { id: 'orders', name: 'orders', team: 'Payments', tier: 'P0', region: 'us-east', version: 'v6.1.2', p95: 230, rps: 1500, err: 0.3, cpu: 41 },\n { id: 'inventory', name: 'inventory', team: 'Data', tier: 'P1', region: 'us-west', version: 'v2.2.9', p95: 205, rps: 880, err: 0.4, cpu: 35 },\n { id: 'analytics-ingest', name: 'analytics-ingest', team: 'Data', tier: 'P2', region: 'us-west', version: 'v9.0.0', p95: 610, rps: 4100, err: 1.1, cpu: 78 },\n { id: 'media', name: 'media', team: 'Growth', tier: 'P2', region: 'ap-south', version: 'v1.6.3', p95: 330, rps: 640, err: 0.6, cpu: 33 },\n ]\n const base = new Map<string, Baseline>(SEED.map((s) => [s.id, { p95: s.p95, rps: s.rps, err: s.err, cpu: s.cpu }]))\n const teamOf = (serviceId: string) => SEED.find((s) => s.id === serviceId)?.team ?? 'Platform'\n\n const statusOf = (s: { p95: number; errorPct: number }): Status =>\n s.errorPct > ERROR_LIMIT ? 'down' : s.p95 > SLO_P95 || s.errorPct > 2 ? 'degraded' : 'healthy'\n const round = (n: number, d = 0) => Math.round(n * 10 ** d) / 10 ** d\n\n // A seeded random walk, so the first frames look the same every visit.\n let rngState = 7\n const rnd = () => ((rngState = (rngState * 16807) % 2147483647) / 2147483647)\n const shaOf = () => Array.from({ length: 7 }, () => '0123456789abcdef'[Math.floor(rnd() * 16)]).join('')\n\n const firstRows: Service[] = SEED.map((s) => {\n const hist = Array.from({ length: HISTORY }, () => round(s.p95 * (0.9 + rnd() * 0.2)))\n const row = { ...s, rps: s.rps, p95: hist[HISTORY - 1]!, errorPct: s.err, cpu: s.cpu, p95History: hist }\n return { ...row, status: statusOf(row) }\n })\n let services = $state.raw<Service[]>(firstRows)\n // Percentile and error histories live beside the rows; the grid has no use for them.\n const p50Hist = new Map<string, number[]>(firstRows.map((s) => [s.id, s.p95History.map((v) => round(v * (0.48 + rnd() * 0.06)))]))\n const p99Hist = new Map<string, number[]>(firstRows.map((s) => [s.id, s.p95History.map((v) => round(v * (1.5 + rnd() * 0.3)))]))\n const errHistory = new Map<string, number[]>(SEED.map((s) => [s.id, Array.from({ length: HISTORY }, () => round(s.err * (0.8 + rnd() * 0.4), 2))]))\n let histVersion = $state(0)\n const chaos = new Map<string, Chaos>()\n\n // Wall clock for relative times and the UTC readout, so \"3m ago\" keeps moving.\n let now = $state(Date.now())\n $effect(() => {\n const t = setInterval(() => (now = Date.now()), 1000)\n return () => clearInterval(t)\n })\n const utc = $derived(new Date(now).toISOString().slice(11, 19))\n\n let paused = $state(false)\n let tickCount = 0\n const hms = (t: number) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })\n let clock = $state<string[]>(Array.from({ length: HISTORY }, (_, i) => hms(Date.now() - (HISTORY - 1 - i) * TICK_MS)))\n\n function tick() {\n if (paused) return\n tickCount++\n // One scripted bad deploy a few seconds in, so the loop runs without a click.\n if (tickCount === 2) ship('checkout-api', 'latency', 'ci-bot', 'Batch tax lookups per cart instead of per line')\n advanceDeploys()\n services = services.map((s) => {\n const b = base.get(s.id)!\n const c = chaos.get(s.id)\n const wobble = () => 1 + (rnd() - 0.5) * 0.18\n let p95 = b.p95 * wobble()\n let errorPct = b.err * (0.7 + rnd() * 0.6)\n let rps = b.rps * wobble()\n let cpu = b.cpu * (0.92 + rnd() * 0.16)\n if (c) {\n const ramp = Math.min(1, (40 - c.ticks) / 4 + 0.25)\n // Additive, so even a 40 ms service crosses the SLO when it breaks.\n if (c.kind === 'latency') { p95 += (900 + rnd() * 600) * ramp; cpu = Math.min(99, cpu * 1.45); errorPct += 1.2 * ramp }\n else { errorPct = 6 + rnd() * 6 * ramp; rps *= 0.82 }\n c.ticks--\n if (c.ticks <= 0) chaos.delete(s.id)\n }\n const next = { ...s, p95: round(p95), errorPct: round(errorPct, 2), rps: round(rps), cpu: round(cpu), p95History: [...s.p95History.slice(1), round(p95)] }\n push(p50Hist, s.id, round(p95 * (0.48 + rnd() * 0.06)))\n push(p99Hist, s.id, round(p95 * (1.5 + rnd() * 0.3)))\n push(errHistory, s.id, next.errorPct)\n return { ...next, status: statusOf(next) }\n })\n histVersion++\n clock = [...clock.slice(1), hms(Date.now())]\n }\n function push(map: Map<string, number[]>, id: string, v: number) {\n const arr = map.get(id)!\n arr.push(v)\n arr.shift()\n }\n $effect(() => {\n const t = setInterval(tick, TICK_MS)\n return () => clearInterval(t)\n })\n\n // Timers the workspace starts (page acknowledgements); cleared on unmount.\n const timers = new Set<ReturnType<typeof setTimeout>>()\n const later = (ms: number, fn: () => void) => {\n const t = setTimeout(() => { timers.delete(t); fn() }, ms)\n timers.add(t)\n }\n $effect(() => () => { for (const t of timers) clearTimeout(t) })\n\n const ago = (t: number) => {\n const s = Math.max(0, Math.round((now - t) / 1000))\n if (s < 45) return 'just now'\n const m = Math.round(s / 60)\n return m < 60 ? `${m}m ago` : `${Math.floor(m / 60)}h ${m % 60}m ago`\n }\n const age = (iso: string) => {\n const m = Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))\n return m < 1 ? 'now' : m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`\n }\n const clockTime = (t: number) => new Date(t).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })\n\n // ---- Deploys ----------------------------------------------------------------\n let deploySeq = 0\n const minutesAgo = (m: number) => Date.now() - m * 60_000\n const liveDeploy = (d: Omit<Deploy, 'id' | 'kind' | 'state' | 'progress' | 'sha'>): Deploy => ({ id: ++deploySeq, kind: 'deploy', state: 'live', progress: 100, sha: shaOf(), ...d })\n let deploys = $state<Deploy[]>([\n liveDeploy({ service: 'search', version: 'v12.4.2', from: 'v12.4.1', author: 'sam', at: minutesAgo(38), message: 'Cache facet counts per shard' }),\n liveDeploy({ service: 'auth', version: 'v8.0.3', from: 'v8.0.2', author: 'owen', at: minutesAgo(95), message: 'Rotate signing keys without dropping sessions' }),\n liveDeploy({ service: 'catalog', version: 'v3.9.1', from: 'v3.9.0', author: 'grace', at: minutesAgo(160), message: 'Variant images served from the CDN' }),\n liveDeploy({ service: 'orders', version: 'v6.1.2', from: 'v6.1.1', author: 'priya', at: minutesAgo(240), message: 'Idempotency keys on order create' }),\n ])\n const bump = (v: string) => v.replace(/(\\d+)$/, (n) => String(Number(n) + 1))\n\n /** A deploy: three ticks of progress, then live. A bad one turns its service sour. */\n function ship(id: string, kind: Chaos['kind'] | undefined, author: string, message: string) {\n const s = services.find((x) => x.id === id)\n if (!s) return\n const pending = deploys.find((d) => d.service === id && d.state === 'deploying')\n if (pending) return\n deploys = [{ id: ++deploySeq, service: id, version: bump(s.version), from: s.version, author, at: Date.now(), sha: shaOf(), message, kind: 'deploy', state: 'deploying', progress: 8, chaos: kind }, ...deploys]\n }\n function advanceDeploys() {\n if (!deploys.some((d) => d.state === 'deploying')) return\n deploys = deploys.map((d) => {\n if (d.state !== 'deploying') return d\n const progress = Math.min(100, d.progress + 34)\n if (progress < 100) return { ...d, progress }\n services = services.map((x) => (x.id === d.service ? { ...x, version: d.version } : x))\n if (d.chaos) chaos.set(d.service, { kind: d.chaos, ticks: 40 })\n untrack(() => toast(`${d.service} ${d.version} is live`, { title: 'Deploy finished' }))\n return { ...d, progress: 100, state: 'live' as const, at: Date.now() }\n })\n }\n\n /** Only the newest live entry per service can be undone, and only a deploy. */\n const latestEntry = $derived(deploys.reduceRight((m, d) => m.set(d.service, d.id), new Map<string, number>()))\n const canRollback = (d: Deploy) => d.kind === 'deploy' && d.state === 'live' && latestEntry.get(d.service) === d.id\n function rollback(d: Deploy) {\n chaos.delete(d.service)\n services = services.map((x) => (x.id === d.service ? { ...x, version: d.from } : x))\n deploys = [\n { id: ++deploySeq, service: d.service, version: d.from, from: d.version, author: 'you', at: Date.now(), sha: d.sha, message: `Revert \"${d.message}\"`, kind: 'rollback', state: 'live', progress: 100 },\n ...deploys.map((x) => (x.id === d.id ? { ...x, rolledBack: true } : x)),\n ]\n const moved = incidents.filter((i) => i.service === d.service && (i.status === 'triage' || i.status === 'investigating'))\n for (const i of incidents.filter((x) => x.service === d.service && x.status !== 'resolved')) log(i.id, ME, `Rolled back ${d.service} to ${d.from}`, 'deploy')\n for (const i of moved) setStatus(i.id, 'mitigated', ME)\n toast.success(`${d.service} rolled back to ${d.from}${moved.length ? `; ${moved.map((i) => i.id).join(', ')} moved to Mitigated` : ''}`)\n }\n\n // ---- Incidents ----------------------------------------------------------------\n let incSeq = 1040\n const isoAgo = (m: number) => new Date(minutesAgo(m)).toISOString()\n const STATUS_LABEL: Record<IncStatus, string> = { triage: 'Triage', investigating: 'Investigating', mitigated: 'Mitigated', resolved: 'Resolved' }\n let incidents = $state<Incident[]>([\n { id: `INC-${++incSeq}`, title: 'search: slow facet queries in eu-west', service: 'search', severity: 'SEV3', status: 'investigating', commander: 'Sam Ortiz', opened: isoAgo(52), source: 'manual',\n timeline: [\n { at: minutesAgo(52), who: 'Ivy Okafor', text: 'Opened from a support ticket', kind: 'open' },\n { at: minutesAgo(49), who: 'Sam Ortiz', text: 'Took command', kind: 'ack' },\n { at: minutesAgo(31), who: 'Sam Ortiz', text: 'Status: Triage to Investigating', kind: 'status' },\n { at: minutesAgo(18), who: 'Sam Ortiz', text: 'Only shards 3 and 7 are slow; their cache hit rate dropped after the reindex', kind: 'note' },\n ] },\n { id: `INC-${++incSeq}`, title: 'analytics-ingest: consumer lag above 2 min', service: 'analytics-ingest', severity: 'SEV3', status: 'mitigated', commander: 'Mia Chen', opened: isoAgo(130), source: 'alert',\n timeline: [\n { at: minutesAgo(130), who: 'Alert rules', text: 'Opened: consumer lag above 2 min', kind: 'open' },\n { at: minutesAgo(128), who: 'Mia Chen', text: 'Acknowledged the page', kind: 'ack' },\n { at: minutesAgo(96), who: 'Mia Chen', text: 'Scaled consumers from 6 to 10; lag is draining', kind: 'note' },\n { at: minutesAgo(80), who: 'Mia Chen', text: 'Status: Investigating to Mitigated', kind: 'status' },\n ] },\n { id: `INC-${++incSeq}`, title: 'auth: token refresh 401s after rotation', service: 'auth', severity: 'SEV2', status: 'resolved', commander: 'Owen Bradley', opened: isoAgo(400), source: 'alert',\n timeline: [\n { at: minutesAgo(400), who: 'Alert rules', text: 'Opened: error rate over 5%', kind: 'open' },\n { at: minutesAgo(397), who: 'Owen Bradley', text: 'Acknowledged the page', kind: 'ack' },\n { at: minutesAgo(360), who: 'Owen Bradley', text: 'Status: Mitigated to Resolved', kind: 'status' },\n ] },\n ])\n const openIncidents = $derived(incidents.filter((i) => i.status !== 'resolved').length)\n const openSev1 = $derived(incidents.filter((i) => i.status !== 'resolved' && i.severity === 'SEV1').length)\n\n function log(id: string, who: string, text: string, kind: TimelineEntry['kind']) {\n incidents = incidents.map((i) => (i.id === id ? { ...i, timeline: [...i.timeline, { at: Date.now(), who, text, kind }] } : i))\n }\n function setStatus(id: string, status: IncStatus, who = ME) {\n const inc = incidents.find((i) => i.id === id)\n if (!inc || inc.status === status) return\n log(id, who, `Status: ${STATUS_LABEL[inc.status]} to ${STATUS_LABEL[status]}`, 'status')\n incidents = incidents.map((i) => (i.id === id ? { ...i, status } : i))\n }\n function setSeverity(id: string, severity: Severity, who = ME) {\n const inc = incidents.find((i) => i.id === id)\n if (!inc || inc.severity === severity) return\n log(id, who, `Severity: ${inc.severity} to ${severity}`, 'sev')\n incidents = incidents.map((i) => (i.id === id ? { ...i, severity } : i))\n }\n function setCommander(id: string, commander: string) {\n log(id, ME, `Commander: ${commander}`, 'ack')\n incidents = incidents.map((i) => (i.id === id ? { ...i, commander } : i))\n }\n /** Page a person; they acknowledge a few seconds later, as on-call people do. */\n function page(person: string, team: string, incId?: string) {\n const targets = incId ? [incId] : incidents.filter((i) => i.status !== 'resolved' && teamOf(i.service) === team).map((i) => i.id)\n for (const id of targets) log(id, 'Paging', `Paged ${person} (${team})`, 'page')\n toast(`Paged ${person}`, { title: `${team} on-call` })\n later(4000 + Math.round(rnd() * 3000), () => {\n for (const id of targets) log(id, person, 'Acknowledged the page', 'ack')\n toast.success(`${person} acknowledged`)\n })\n }\n\n function openIncidentFor(e: AlertEvent) {\n const svc = services.find((s) => s.id === e.rowId)\n if (!svc) return\n const errors = e.ruleId === RULE_ERRORS\n const severity: Severity = svc.tier === 'P0' ? (errors ? 'SEV1' : 'SEV2') : errors ? 'SEV2' : 'SEV3'\n const existing = incidents.find((i) => i.service === svc.id && i.status !== 'resolved')\n if (existing) {\n // Escalate an open incident rather than opening a second one.\n if (severity < existing.severity) {\n setSeverity(existing.id, severity, 'Alert rules')\n toast.warning(`${existing.id} escalated to ${severity}`)\n }\n return\n }\n const rota = ROTA[svc.team]\n const inc: Incident = {\n id: `INC-${++incSeq}`,\n title: errors ? `${svc.name}: error rate above ${ERROR_LIMIT}%` : `${svc.name}: p95 latency over the ${SLO_P95} ms SLO`,\n service: svc.id, severity, status: 'triage', commander: rota?.primary ?? 'Unassigned', opened: new Date().toISOString(), source: 'alert',\n timeline: [{ at: Date.now(), who: 'Alert rules', text: `Opened: ${e.ruleName}`, kind: 'open' }],\n }\n incidents = [inc, ...incidents]\n if (rota) page(rota.primary, svc.team, inc.id)\n }\n function newIncident() {\n const svc = services.find((s) => s.id === selectedId) ?? services[0]!\n const id = `INC-${++incSeq}`\n incidents = [{ id, title: `${svc.name}: reported by support`, service: svc.id, severity: 'SEV3', status: 'triage', commander: ME, opened: new Date().toISOString(), source: 'manual',\n timeline: [{ at: Date.now(), who: ME, text: 'Opened by hand', kind: 'open' }] }, ...incidents]\n drawerId = id\n }\n function onCardMove(e: BoardCardMoveEvent<Incident>) {\n setStatus(e.row.id, e.toLane as IncStatus)\n }\n\n // ---- Incident drawer --------------------------------------------------------\n let drawerId = $state<string | null>(null)\n const drawerInc = $derived(incidents.find((i) => i.id === drawerId) ?? null)\n const drawerSvc = $derived(drawerInc ? services.find((s) => s.id === drawerInc.service) : undefined)\n const drawerDeploy = $derived(drawerInc ? deploys.find((d) => d.service === drawerInc.service && d.kind === 'deploy') : undefined)\n let note = $state('')\n function addNote() {\n const text = note.trim()\n if (!text || !drawerInc) return\n log(drawerInc.id, ME, text, 'note')\n note = ''\n }\n\n // ---- Alert rules (Enterprise) ---------------------------------------------\n const RULE_P95 = 'ops-p95-slo'\n const RULE_ERRORS = 'ops-error-rate'\n const OUR_RULES = new Set([RULE_P95, RULE_ERRORS])\n const exprColumns: ExprColumn[] = [\n { id: 'name', name: 'Service', type: 'text' },\n { id: 'team', name: 'Team', type: 'text' },\n { id: 'tier', name: 'Tier', type: 'text' },\n { id: 'region', name: 'Region', type: 'text' },\n { id: 'rps', name: 'Requests / s', type: 'number' },\n { id: 'p95', name: 'p95 latency (ms)', type: 'number' },\n { id: 'errorPct', name: 'Error rate (%)', type: 'number' },\n { id: 'cpu', name: 'CPU (%)', type: 'number' },\n ]\n const seededRules: AlertRule[] = [\n {\n id: RULE_P95, name: `p95 over the ${SLO_P95} ms SLO`, enabled: true, severity: 'warning', scope: 'row',\n predicate: { kind: 'cmp', column: 'p95', op: 'greaterThan', value: SLO_P95 },\n trigger: { type: 'dataChange' },\n actions: [{ kind: 'toast', message: '{name}: p95 is {value} ms' }, { kind: 'highlight', style: { background: '#fef3c7', color: '#92400e' } }],\n createdAt: 0,\n },\n {\n id: RULE_ERRORS, name: `Error rate over ${ERROR_LIMIT}%`, enabled: true, severity: 'error', scope: 'row',\n predicate: { kind: 'cmp', column: 'errorPct', op: 'greaterThan', value: ERROR_LIMIT },\n trigger: { type: 'dataChange' },\n actions: [{ kind: 'toast', message: '{name}: error rate {value}%' }, { kind: 'highlight', style: { background: '#fee2e2', color: '#991b1b' } }],\n createdAt: 0,\n },\n ]\n let alertFormats = $state<ConditionalFormat<Service>[]>([])\n\n // The alert log is shared across the page; only events from this\n // workspace's rules count, and only the ones fired after it mounted.\n const keyOf = (e: AlertEvent) => `${e.ruleId}|${e.rowId ?? ''}|${e.firedAt}`\n const mountedAt = Date.now()\n const seen = new Set(alertStore.events.map(keyOf))\n const feed = $derived(alertStore.events.filter((e) => OUR_RULES.has(e.ruleId) && e.firedAt >= mountedAt))\n const unacked = $derived(feed.filter((e) => !e.acknowledged).length)\n /** Acknowledge this workspace's alerts only; the log is shared with the page. */\n function ackAll() {\n for (const e of feed) {\n if (!e.acknowledged) alertStore.acknowledge(alertStore.events.indexOf(e))\n }\n }\n const incidentForService = (id: string | undefined) => incidents.find((i) => i.service === id && i.status !== 'resolved')\n $effect(() => {\n const events = alertStore.events\n untrack(() => {\n for (const e of [...events].reverse()) {\n const k = keyOf(e)\n if (seen.has(k)) continue\n seen.add(k)\n if (OUR_RULES.has(e.ruleId) && e.rowId) openIncidentFor(e)\n }\n })\n })\n\n // ---- Selection + charts -------------------------------------------------------\n let selectedId = $state('checkout-api')\n const selected = $derived(services.find((s) => s.id === selectedId) ?? services[0]!)\n const latencySpec = $derived.by<ChartSpec>(() => {\n void histVersion\n return {\n type: 'line',\n categories: clock,\n xAxis: { labelRotation: 0 },\n series: [\n { label: 'p99', values: [...(p99Hist.get(selected.id) ?? [])], color: '#93c5fd', marker: 'none', smooth: 'monotone' },\n { label: 'p95', values: selected.p95History, color: '#2563eb', type: 'area', marker: 'none', smooth: 'monotone' },\n { label: 'p50', values: [...(p50Hist.get(selected.id) ?? [])], color: '#64748b', marker: 'none', smooth: 'monotone' },\n ],\n referenceLines: [{ value: SLO_P95, label: `SLO ${SLO_P95} ms`, dashed: true, color: '#dc2626' }],\n yAxisTitle: 'ms',\n }\n })\n const errorSpec = $derived.by<ChartSpec>(() => {\n void histVersion\n return {\n type: 'area',\n categories: clock,\n xAxis: { labelRotation: 0 },\n series: [{ label: 'Errors (%)', values: [...(errHistory.get(selected.id) ?? [])], color: '#d97706', marker: 'none', smooth: 'monotone' }],\n referenceLines: [{ value: ERROR_LIMIT, label: `Limit ${ERROR_LIMIT}%`, dashed: true, color: '#dc2626' }],\n yAxisTitle: '%',\n }\n })\n\n // ---- KPIs ---------------------------------------------------------------------\n const healthy = $derived(services.filter((s) => s.status === 'healthy').length)\n const fleetP95 = $derived.by(() => {\n const sorted = services.map((s) => s.p95).sort((a, b) => a - b)\n return sorted[Math.floor(sorted.length / 2)] ?? 0\n })\n const totalRps = $derived(services.reduce((n, s) => n + s.rps, 0))\n const fleetErr = $derived(round(services.reduce((n, s) => n + s.errorPct * s.rps, 0) / Math.max(1, totalRps), 2))\n\n // ---- Grids --------------------------------------------------------------------\n let servicesApi = $state<EnterpriseGridApi<any, Service> | null>(null)\n let incidentsApi = $state<EnterpriseGridApi<any, Incident> | null>(null)\n let incidentView = $state<'board' | 'table'>('board')\n\n const serviceColumns: ColumnDef<any, Service>[] = [\n { id: 'name', field: 'name', header: 'Service', width: 190, cell: (ctx) => renderSnippet(ServiceCell, { row: ctx.row.original }) },\n { id: 'status', field: 'status', header: 'Status', width: 104, cell: (ctx) => renderSnippet(StatusCell, { row: ctx.row.original }) },\n { id: 'tier', field: 'tier', header: 'Tier', width: 64 },\n { id: 'p95', field: 'p95', header: 'p95 ms', width: 88, format: { type: 'number' } },\n { id: 'trend', field: 'p95History', header: 'p95, last 60 s', width: 130, sparkline: { type: 'area', color: '#2563eb', lineWidth: 1.5 } },\n { id: 'errorPct', field: 'errorPct', header: 'Errors', width: 84, cell: (ctx) => renderSnippet(ErrorCell, { row: ctx.row.original }) },\n { id: 'rps', field: 'rps', header: 'Req/s', width: 88, format: { type: 'number' } },\n { id: 'cpu', field: 'cpu', header: 'CPU %', width: 76, format: { type: 'number' } },\n { id: 'region', field: 'region', header: 'Region', width: 92 },\n { id: 'version', field: 'version', header: 'Version', width: 96 },\n ]\n const incidentColumns: ColumnDef<any, Incident>[] = [\n { field: 'id', header: 'ID', width: 96, editable: false },\n { field: 'title', header: 'Title', width: 280, editorType: 'text' },\n { field: 'severity', header: 'Sev', width: 80, editorType: 'list', editorOptions: ['SEV1', 'SEV2', 'SEV3'] },\n { field: 'status', header: 'Status', width: 130, editorType: 'list', editorOptions: ['triage', 'investigating', 'mitigated', 'resolved'] },\n { field: 'commander', header: 'Commander', width: 140, editorType: 'text' },\n { field: 'service', header: 'Service', width: 140, editable: false },\n { field: 'opened', header: 'Opened', width: 150, editable: false, format: { type: 'datetime', pattern: 'short' } },\n ]\n const lanes = [\n { id: 'triage', title: 'Triage', color: '#dc2626' },\n { id: 'investigating', title: 'Investigating', color: '#d97706' },\n { id: 'mitigated', title: 'Mitigated', color: '#2563eb' },\n { id: 'resolved', title: 'Resolved', color: '#16a34a' },\n ]\n\n // ---- Export (Enterprise) ------------------------------------------------------\n const SERVICE_EXPORT: ExportColumn<Service>[] = [\n { field: 'name', header: 'Service' }, { field: 'team', header: 'Team' }, { field: 'tier', header: 'Tier' },\n { field: 'status', header: 'Status' }, { field: 'p95', header: 'p95 (ms)' }, { field: 'errorPct', header: 'Error rate (%)' },\n { field: 'rps', header: 'Requests / s' }, { field: 'cpu', header: 'CPU (%)' }, { field: 'region', header: 'Region' }, { field: 'version', header: 'Version' },\n ]\n const INCIDENT_EXPORT: ExportColumn<Incident>[] = [\n { field: 'id', header: 'ID' }, { field: 'title', header: 'Title' }, { field: 'severity', header: 'Severity' }, { field: 'status', header: 'Status' },\n { field: 'commander', header: 'Commander' }, { field: 'service', header: 'Service' },\n { field: 'opened', header: 'Opened', format: { type: 'datetime', pattern: 'short' } }, { field: 'source', header: 'Source' },\n ]\n const stamp = () => {\n const d = new Date()\n const pad = (n: number) => String(n).padStart(2, '0')\n return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`\n }\n async function run(label: string, job: () => Promise<unknown> | undefined) {\n try {\n await job()\n toast.success(`Exported ${label}`)\n } catch (err) {\n toast.error(`Export failed: ${err instanceof Error ? err.message : String(err)}`)\n }\n }\n const exportServices = (format: ExportFormat) =>\n run(`services to ${format.toUpperCase()}`, () => servicesApi?.exportData({ format, filename: `services-${stamp()}`, columns: SERVICE_EXPORT, rows: 'all' }))\n const exportIncidents = (format: ExportFormat) =>\n run(`incidents to ${format.toUpperCase()}`, () => incidentsApi?.exportData({ format, filename: `incidents-${stamp()}`, columns: INCIDENT_EXPORT, rows: incidents }))\n const exportItems: MenuItem[] = [\n { label: 'Services snapshot to Excel', onSelect: () => exportServices('xlsx') },\n { label: 'Services snapshot to PDF', onSelect: () => exportServices('pdf') },\n { separator: true },\n { label: 'Incident log to Excel', onSelect: () => exportIncidents('xlsx') },\n { label: 'Incident log to CSV', onSelect: () => exportIncidents('csv') },\n ]\n const injectItems = $derived<MenuItem[]>([\n { label: `Bad deploy on ${selected.name}: latency`, onSelect: () => ship(selected.id, 'latency', 'you', 'Switch the connection pool to lazy init') },\n { label: `Bad deploy on ${selected.name}: errors`, onSelect: () => ship(selected.id, 'errors', 'you', 'Tighten request validation') },\n { separator: true },\n { label: `Clean deploy on ${selected.name}`, onSelect: () => ship(selected.id, undefined, 'you', 'Bump dependencies') },\n ])\n\n // ---- Dock layout ----------------------------------------------------------------\n // The board sits under the service grid, in the wide column, so all four\n // lanes fit; the charts and logs are narrow by nature.\n const LAYOUT_KEY = 'svgrid-demo-ops-layout-v3'\n const defaultLayout = (): DockManagerState => ({\n main: dockGroup('row', [\n dockGroup('column', [\n dockTabs([dockPane('services', 'Services', { closable: false, minSize: 180 })]),\n dockTabs([dockPane('incidents', 'Incidents', { minSize: 200 })]),\n ], [0.5, 0.5]),\n dockGroup('column', [\n dockTabs([dockPane('latency', 'Latency'), dockPane('errors', 'Errors')]),\n dockTabs([dockPane('deploys', 'Deploys'), dockPane('oncall', 'On-call'), dockPane('feed', 'Alert feed')]),\n ], [0.5, 0.5]),\n ], [0.64, 0.36]),\n floating: [],\n autoHide: [],\n })\n function loadLayout(): DockManagerState {\n try {\n const s = localStorage.getItem(LAYOUT_KEY)\n if (s) return JSON.parse(s) as DockManagerState\n } catch {\n /* storage blocked or malformed: fall back to the default */\n }\n return defaultLayout()\n }\n let workspace = $state<DockManagerState>(loadLayout())\n function saveLayout() {\n try {\n localStorage.setItem(LAYOUT_KEY, JSON.stringify(workspace))\n toast.success('Layout saved; it comes back on reload')\n } catch {\n toast.error('Could not save: storage is blocked in this browser')\n }\n }\n function resetLayout() {\n workspace = defaultLayout()\n try { localStorage.removeItem(LAYOUT_KEY) } catch { /* ignore */ }\n }\n\n function onServicesReady(a: SvGridApi<any, Service>) { servicesApi = installEnterprise(a) }\n function onIncidentsReady(a: SvGridApi<any, Incident>) { incidentsApi = installEnterprise(a) }\n\n // Status and severity colours carry meaning, so they stay literal.\n const STATUS_COLOR: Record<Status, string> = { healthy: '#16a34a', degraded: '#d97706', down: '#dc2626' }\n const SEV_COLOR: Record<Severity, string> = { SEV1: '#dc2626', SEV2: '#d97706', SEV3: '#64748b' }\n const SEVERITY_COLOR: Record<string, string> = { error: '#dc2626', warning: '#d97706', info: '#2563eb', success: '#16a34a' }\n const LANE_COLOR: Record<IncStatus, string> = { triage: '#dc2626', investigating: '#d97706', mitigated: '#2563eb', resolved: '#16a34a' }\n const TL_ICON: Record<TimelineEntry['kind'], string> = {\n open: 'M12 8v4 M12 16h.01 M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z',\n status: 'M5 12h14 M13 6l6 6-6 6',\n sev: 'M12 3 2 20h20z M12 10v4 M12 17h.01',\n page: 'M6 16v-5a6 6 0 0 1 12 0v5l2 2H4z M10 21h4',\n ack: 'M5 13l4 4L19 7',\n note: 'M4 5h16v11H8l-4 4z',\n deploy: 'M9 14 4 9l5-5 M4 9h10a6 6 0 0 1 0 12h-3',\n }\n const SEVERITIES: Severity[] = ['SEV1', 'SEV2', 'SEV3']\n const STATUSES: IncStatus[] = ['triage', 'investigating', 'mitigated', 'resolved']\n const ROTA_ROWS = Object.entries(ROTA)\n</script>\n\n{#snippet ico(d: string, size = 15)}\n <svg viewBox=\"0 0 24 24\" width={size} height={size} fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path {d} /></svg>\n{/snippet}\n\n{#snippet ServiceCell(props: { row: Service })}\n <span class=\"ops-svc\">\n <span class=\"ops-dot\" style:background={STATUS_COLOR[props.row.status]}></span>\n <span class=\"ops-svc-txt\"><strong>{props.row.name}</strong><span>{props.row.team}</span></span>\n </span>\n{/snippet}\n{#snippet StatusCell(props: { row: Service })}\n <span class=\"ops-pill\" style:--c={STATUS_COLOR[props.row.status]}>{props.row.status}</span>\n{/snippet}\n{#snippet ErrorCell(props: { row: Service })}\n <span class=\"ops-num\" class:is-bad={props.row.errorPct > ERROR_LIMIT} class:is-warn={props.row.errorPct > 2 && props.row.errorPct <= ERROR_LIMIT}>{props.row.errorPct.toFixed(2)}%</span>\n{/snippet}\n{#snippet incidentCard(i: Incident)}\n <!-- svelte-ignore a11y_click_events_have_key_events -->\n <!-- svelte-ignore a11y_no_static_element_interactions -->\n <div class=\"ops-card\" onclick={() => (drawerId = i.id)} title=\"Open {i.id}\">\n <div class=\"ops-card-top\">\n <span class=\"ops-sev\" style:--c={SEV_COLOR[i.severity]}>{i.severity}</span>\n <span class=\"ops-card-id\">{i.id}</span>\n <span class=\"ops-card-age\">{age(i.opened)}</span>\n </div>\n <div class=\"ops-card-title\">{i.title}</div>\n <div class=\"ops-card-foot\">\n <SvAvatar name={i.commander} size={18} />\n <span class=\"ops-card-who\">{i.commander}</span>\n {#if i.source === 'alert'}<span class=\"ops-card-src\" title=\"Opened by an alert rule\" aria-label=\"Opened by an alert rule\">{@render ico('M6 16v-5a6 6 0 0 1 12 0v5l2 2H4z M10 21h4', 12)}</span>{/if}\n </div>\n </div>\n{/snippet}\n\n<div class=\"ops\">\n <header class=\"ops-head\">\n <div class=\"ops-title\">\n <span class=\"ops-logo\">{@render ico('M3 12h4l3-8 4 16 3-8h4', 18)}</span>\n <div>\n <h2>Ops center <span class=\"ops-live\" class:is-paused={paused}>{paused ? 'Paused' : 'Live'}</span></h2>\n <p><span class=\"ops-env\">production</span> us-east-1 · {services.length} services · <span class=\"ops-utc\">{utc} UTC</span></p>\n </div>\n </div>\n <div class=\"ops-tools\">\n <button type=\"button\" class=\"ops-btn\" onclick={() => (paused = !paused)}>{@render ico(paused ? 'M8 5v14l11-7z' : 'M7 5h3v14H7z M14 5h3v14h-3z')}{paused ? 'Resume' : 'Pause'}</button>\n <SvMenu items={injectItems}>\n {#snippet anchor()}<button type=\"button\" class=\"ops-btn\">{@render ico('M12 19V5 M5 12l7-7 7 7')}Deploy</button>{/snippet}\n </SvMenu>\n <SvMenu items={exportItems}>\n {#snippet anchor()}<button type=\"button\" class=\"ops-btn\">{@render ico('M12 3v12 M8 11l4 4 4-4 M4 17v3h16v-3')}Export</button>{/snippet}\n </SvMenu>\n <button type=\"button\" class=\"ops-btn\" onclick={saveLayout}>Save layout</button>\n <button type=\"button\" class=\"ops-btn\" onclick={resetLayout}>Reset</button>\n <SvGridAlerts data={services} columns={exprColumns} getRowId={(s) => s.id} rules={seededRules} storageKey=\"svgrid-demo:ops-alerts\" bind:formats={alertFormats} toastCooldownMs={8000} onJump={(e) => { if (e.rowId) selectedId = e.rowId }} />\n </div>\n </header>\n\n <div class=\"ops-kpis\">\n <div class=\"ops-kpi\"><span>Healthy</span><strong class:is-warn={healthy < services.length}>{healthy}<small> / {services.length}</small></strong></div>\n <div class=\"ops-kpi\"><span>Median p95</span><strong>{fleetP95}<small> ms</small></strong></div>\n <div class=\"ops-kpi\"><span>Error rate</span><strong class:is-bad={fleetErr > 2}>{fleetErr.toFixed(2)}<small>%</small></strong></div>\n <div class=\"ops-kpi\"><span>Traffic</span><strong>{Math.round(totalRps / 100) / 10}<small>k req/s</small></strong></div>\n <div class=\"ops-kpi\"><span>Open incidents</span><strong class:is-bad={openSev1 > 0}>{openIncidents}{#if openSev1}<small> ({openSev1} SEV1)</small>{/if}</strong></div>\n <div class=\"ops-kpi\"><span>Alerts fired</span><strong>{feed.length}{#if unacked}<small> ({unacked} new)</small>{/if}</strong></div>\n </div>\n\n <div class=\"ops-stage\">\n <SvDockManager bind:workspace minSize={120} keepAlive>\n {#snippet pane(p)}\n {#if p.id === 'services'}\n <div class=\"ops-pane\">\n <SvGrid\n data={services}\n columns={serviceColumns}\n getRowId={(s) => s.id}\n conditionalFormats={alertFormats}\n sortable\n selectionMode=\"none\"\n enableRowSummaries={false}\n rowHeight={44}\n containerHeight=\"100%\"\n initialColumnPinning={{ left: ['name'] }}\n columnVirtualization={false}\n rowClass={({ row }) => ({ 'ops-row-sel': row.id === selectedId })}\n onRowClick={(e) => (selectedId = e.row.id)}\n onApiReady={onServicesReady}\n />\n </div>\n {:else if p.id === 'latency' || p.id === 'errors'}\n {@const inc = incidentForService(selected.id)}\n <div class=\"ops-pane ops-chart\">\n <div class=\"ops-chart-head\">\n <strong>{selected.name}</strong>\n <span class=\"ops-pill\" style:--c={STATUS_COLOR[selected.status]}>{selected.status}</span>\n <span class=\"ops-muted\">{p.id === 'latency' ? `p95 ${selected.p95} ms` : `errors ${selected.errorPct.toFixed(2)}%`} · {selected.version} · on call {ROTA[selected.team]?.primary}</span>\n {#if inc}<button type=\"button\" class=\"ops-link\" onclick={() => (drawerId = inc.id)}>{inc.id}</button>{/if}\n </div>\n <div class=\"ops-chart-body\">\n <SvChart spec={p.id === 'latency' ? latencySpec : errorSpec} legend={p.id === 'latency'} autosize animate={false} />\n </div>\n </div>\n {:else if p.id === 'incidents'}\n <div class=\"ops-pane ops-inc\">\n <div class=\"ops-inc-bar\">\n <div class=\"ops-seg\" role=\"group\" aria-label=\"Incident view\">\n <button type=\"button\" class:is-on={incidentView === 'board'} aria-pressed={incidentView === 'board'} onclick={() => (incidentView = 'board')}>Board</button>\n <button type=\"button\" class:is-on={incidentView === 'table'} aria-pressed={incidentView === 'table'} onclick={() => (incidentView = 'table')}>Table</button>\n </div>\n <span class=\"ops-muted\">The same grid, two views. {openIncidents} open; click one for its timeline.</span>\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={newIncident}>{@render ico('M12 5v14 M5 12h14', 13)}New</button>\n </div>\n <div class=\"ops-inc-grid\">\n {#if incidentView === 'board'}\n <SvGrid\n data={incidents}\n columns={incidentColumns}\n getRowId={(i) => i.id}\n containerHeight=\"100%\"\n onApiReady={onIncidentsReady}\n board={{ groupBy: 'status', lanes, card: incidentCard, onCardMove, searchable: false, laneSummary: (list) => `${list.length}` }}\n />\n {:else}\n <SvGrid\n data={incidents}\n columns={incidentColumns}\n getRowId={(i) => i.id}\n sortable\n filterable\n enableInlineEditing\n selectionMode=\"none\"\n enableRowSummaries={false}\n rowHeight={36}\n containerHeight=\"100%\"\n onApiReady={onIncidentsReady}\n onRowDoubleClick={(e) => (drawerId = e.row.id)}\n onCellValueChange={() => (incidents = [...incidents])}\n />\n {/if}\n </div>\n </div>\n {:else if p.id === 'deploys'}\n <div class=\"ops-pane ops-list\">\n {#each deploys as d (d.id)}\n <div class=\"ops-dep\">\n <span class=\"ops-dep-ic\" class:is-rb={d.kind === 'rollback'}>{@render ico(d.kind === 'rollback' ? 'M9 14 4 9l5-5 M4 9h10a6 6 0 0 1 0 12h-3' : 'M12 19V5 M5 12l7-7 7 7', 13)}</span>\n <div class=\"ops-dep-txt\">\n <span><strong>{d.service}</strong> {d.from} → {d.version}{#if d.rolledBack}<span class=\"ops-tag\">rolled back</span>{/if}</span>\n <span class=\"ops-dep-msg\">{d.message}</span>\n <span class=\"ops-muted\"><code>{d.sha}</code> · {d.kind === 'rollback' ? `rollback by ${d.author}` : `by ${d.author}`} · {d.state === 'deploying' ? 'deploying' : ago(d.at)}</span>\n {#if d.state === 'deploying'}<span class=\"ops-progress\" role=\"progressbar\" aria-valuenow={d.progress} aria-valuemin={0} aria-valuemax={100} aria-label=\"Deploy progress\"><span style:width=\"{d.progress}%\"></span></span>{/if}\n </div>\n {#if canRollback(d)}\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => rollback(d)}>Roll back</button>\n {/if}\n </div>\n {/each}\n </div>\n {:else if p.id === 'oncall'}\n <div class=\"ops-pane ops-list\">\n {#each ROTA_ROWS as [team, r] (team)}\n {@const open = incidents.filter((i) => i.status !== 'resolved' && teamOf(i.service) === team).length}\n <div class=\"ops-dep\">\n <SvAvatar name={r.primary} size={30} status=\"online\" />\n <div class=\"ops-dep-txt\">\n <span><strong>{r.primary}</strong> <span class=\"ops-muted\">· {team}</span>{#if open}<span class=\"ops-tag ops-tag-bad\">{open} open</span>{/if}</span>\n <span class=\"ops-muted\">Primary until {r.until}:00 · secondary {r.secondary}</span>\n </div>\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => page(r.primary, team)}>Page</button>\n </div>\n {/each}\n </div>\n {:else if p.id === 'feed'}\n <div class=\"ops-pane ops-list\">\n {#if feed.length}\n <div class=\"ops-list-head\">\n <span class=\"ops-muted\">{unacked} unacknowledged of {feed.length}</span>\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" disabled={unacked === 0} onclick={ackAll}>Acknowledge all</button>\n </div>\n {/if}\n {#if feed.length === 0}\n <div class=\"ops-empty\">No alerts yet. The scripted deploy trips one a few seconds after load, or ship a bad one from Deploy.</div>\n {/if}\n {#each feed as e (keyOf(e))}\n {@const inc = incidentForService(e.rowId)}\n <div class=\"ops-alert\" class:is-ack={e.acknowledged}>\n <span class=\"ops-dot\" style:background={SEVERITY_COLOR[e.severity] ?? '#64748b'}></span>\n <div class=\"ops-dep-txt\">\n <span>{e.message}</span>\n <span class=\"ops-muted\">{e.ruleName} · {clockTime(e.firedAt)}{#if inc} · <button type=\"button\" class=\"ops-link\" onclick={() => (drawerId = inc.id)}>{inc.id}</button>{/if}</span>\n </div>\n {#if !e.acknowledged}\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => alertStore.acknowledge(alertStore.events.indexOf(e))}>Ack</button>\n {/if}\n </div>\n {/each}\n </div>\n {/if}\n {/snippet}\n </SvDockManager>\n </div>\n</div>\n\n<SvDrawer open={drawerInc != null} onClose={() => (drawerId = null)} side=\"right\" size=\"440px\" title={drawerInc ? `${drawerInc.id} · ${drawerInc.title}` : ''}>\n {#if drawerInc}\n {@const inc = drawerInc}\n <div class=\"ops-dr\">\n <div class=\"ops-dr-row\">\n <span class=\"ops-dr-label\">Severity</span>\n <div class=\"ops-seg\" role=\"group\" aria-label=\"Severity\">\n {#each SEVERITIES as s (s)}\n <button type=\"button\" class:is-on={inc.severity === s} style:--c={SEV_COLOR[s]} aria-pressed={inc.severity === s} onclick={() => setSeverity(inc.id, s)}>{s}</button>\n {/each}\n </div>\n </div>\n <div class=\"ops-dr-row\">\n <span class=\"ops-dr-label\">Status</span>\n <div class=\"ops-seg\" role=\"group\" aria-label=\"Status\">\n {#each STATUSES as s (s)}\n <button type=\"button\" class:is-on={inc.status === s} aria-pressed={inc.status === s} onclick={() => setStatus(inc.id, s)}>{STATUS_LABEL[s]}</button>\n {/each}\n </div>\n </div>\n <div class=\"ops-dr-row\">\n <span class=\"ops-dr-label\">Commander</span>\n <select class=\"ops-select\" value={inc.commander} onchange={(e) => setCommander(inc.id, e.currentTarget.value)} aria-label=\"Commander\">\n {#each PEOPLE as name (name)}<option value={name}>{name}</option>{/each}\n </select>\n <button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => page(inc.commander, teamOf(inc.service), inc.id)}>Page</button>\n </div>\n\n {#if drawerSvc}\n <div class=\"ops-dr-card\">\n <div class=\"ops-dr-card-head\">\n <span class=\"ops-dot\" style:background={STATUS_COLOR[drawerSvc.status]}></span>\n <strong>{drawerSvc.name}</strong>\n <span class=\"ops-pill\" style:--c={STATUS_COLOR[drawerSvc.status]}>{drawerSvc.status}</span>\n <button type=\"button\" class=\"ops-link\" onclick={() => (selectedId = drawerSvc.id)}>Show in charts</button>\n </div>\n <div class=\"ops-dr-stats\">\n <span><small>p95</small>{drawerSvc.p95} ms</span>\n <span><small>Errors</small>{drawerSvc.errorPct.toFixed(2)}%</span>\n <span><small>Traffic</small>{drawerSvc.rps.toLocaleString()} req/s</span>\n <span><small>Version</small>{drawerSvc.version}</span>\n </div>\n {#if drawerDeploy}\n <div class=\"ops-dr-deploy\">\n <span class=\"ops-muted\">Last deploy {drawerDeploy.from} → {drawerDeploy.version} by {drawerDeploy.author}, {drawerDeploy.state === 'deploying' ? 'in progress' : ago(drawerDeploy.at)}{#if drawerDeploy.rolledBack} (rolled back){/if}</span>\n {#if canRollback(drawerDeploy)}<button type=\"button\" class=\"ops-btn ops-btn-sm\" onclick={() => drawerDeploy && rollback(drawerDeploy)}>Roll back</button>{/if}\n </div>\n {/if}\n </div>\n {/if}\n\n <h3 class=\"ops-dr-h\">Timeline</h3>\n <ol class=\"ops-tl\">\n {#each [...inc.timeline].reverse() as t, i (t.at + t.text + i)}\n <li class=\"ops-tl-item\" data-kind={t.kind}>\n <span class=\"ops-tl-ic\">{@render ico(TL_ICON[t.kind], 13)}</span>\n <div class=\"ops-tl-txt\">\n <span>{#if t.kind === 'note'}<strong>{t.who}</strong> {t.text}{:else}{t.text}{#if t.who !== 'Alert rules' && t.who !== 'Paging' && !t.text.includes(t.who)}{' '}<span class=\"ops-muted\">by {t.who}</span>{/if}{/if}</span>\n <span class=\"ops-muted\">{clockTime(t.at)} · {ago(t.at)}</span>\n </div>\n </li>\n {/each}\n </ol>\n <form class=\"ops-note\" onsubmit={(e) => { e.preventDefault(); addNote() }}>\n <textarea bind:value={note} rows=\"2\" placeholder=\"Add a note to the timeline\" aria-label=\"Note\" onkeydown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); addNote() } }}></textarea>\n <button type=\"submit\" class=\"ops-btn ops-btn-sm\" disabled={!note.trim()}>Add note</button>\n </form>\n </div>\n {/if}\n {#snippet footer()}\n {#if drawerInc}\n {@const inc = drawerInc}\n <div class=\"ops-dr-foot\">\n <span class=\"ops-muted\">Opened {age(inc.opened) === 'now' ? 'just now' : `${age(inc.opened)} ago`} {inc.source === 'alert' ? 'by an alert rule' : 'by hand'}</span>\n {#if inc.status === 'resolved'}\n <button type=\"button\" class=\"ops-btn\" onclick={() => setStatus(inc.id, 'investigating')}>Reopen</button>\n {:else}\n <button type=\"button\" class=\"ops-btn ops-btn-primary\" style:--c={LANE_COLOR.resolved} onclick={() => { setStatus(inc.id, 'resolved'); toast.success(`${inc.id} resolved`) }}>Resolve</button>\n {/if}\n </div>\n {/if}\n {/snippet}\n</SvDrawer>\n\n<SvToaster position=\"bottom-right\" />\n\n<style>\n .ops {\n display: flex;\n flex-direction: column;\n flex: 1 1 auto;\n min-height: 760px;\n height: 100%;\n border: 1px solid var(--sg-border, #e5e7eb);\n border-radius: 12px;\n overflow: hidden;\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n font-size: 13px;\n }\n .ops-muted { color: var(--sg-muted, #64748b); }\n\n .ops-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding: 12px 16px; border-bottom: 1px solid var(--sg-border, #e5e7eb); flex: none; }\n .ops-title { display: flex; align-items: center; gap: 12px; min-width: 0; }\n .ops-logo { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 8px; background: var(--sg-fg, #0f172a); color: var(--sg-bg, #fff); flex: none; }\n .ops-title h2 { margin: 0; font-size: 16px; font-weight: 650; display: flex; align-items: center; gap: 8px; }\n .ops-title p { margin: 2px 0 0; font-size: 12px; color: var(--sg-muted, #64748b); display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }\n /* Production red-ish pill is a warning label, like every deploy tool shows. */\n .ops-env { font-size: 10.5px; font-weight: 700; padding: 0 6px; border-radius: 4px; color: #b91c1c; background: color-mix(in srgb, #dc2626 12%, transparent); }\n .ops-utc { font-variant-numeric: tabular-nums; }\n /* Live / paused are status, so green and amber stay literal. */\n .ops-live { font-size: 10.5px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; color: #15803d; background: color-mix(in srgb, #16a34a 14%, transparent); padding: 2px 8px; border-radius: 999px; }\n .ops-live.is-paused { color: #b45309; background: color-mix(in srgb, #d97706 14%, transparent); }\n .ops-tools { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }\n .ops-btn {\n display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 11px;\n border: 1px solid var(--sg-border, #e5e7eb); border-radius: 7px; background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a); font: inherit; font-size: 12.5px; font-weight: 500; cursor: pointer; white-space: nowrap;\n }\n .ops-btn:hover { background: var(--sg-row-hover-bg, #f1f5f9); }\n .ops-btn:focus-visible { outline: 2px solid var(--sg-accent, #2563eb); outline-offset: 1px; }\n .ops-btn:disabled { opacity: 0.45; cursor: default; }\n .ops-btn-sm { height: 26px; padding: 0 8px; font-size: 12px; }\n .ops-btn-primary { background: var(--c); border-color: var(--c); color: #fff; }\n .ops-btn-primary:hover { background: color-mix(in srgb, var(--c) 88%, #000); }\n .ops-link { border: 0; background: none; padding: 0; font: inherit; font-size: 12px; font-weight: 600; color: var(--sg-accent, #2563eb); cursor: pointer; }\n .ops-link:hover { text-decoration: underline; }\n\n .ops-kpis { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); border-bottom: 1px solid var(--sg-border, #e5e7eb); flex: none; }\n .ops-kpi { display: flex; flex-direction: column; gap: 2px; padding: 9px 16px; border-right: 1px solid var(--sg-border, #e5e7eb); min-width: 0; }\n .ops-kpi:last-child { border-right: 0; }\n .ops-kpi span { font-size: 11px; color: var(--sg-muted, #64748b); white-space: nowrap; }\n .ops-kpi strong { font-size: 19px; font-weight: 650; font-variant-numeric: tabular-nums; line-height: 1.15; white-space: nowrap; }\n .ops-kpi small { font-size: 12px; font-weight: 500; color: var(--sg-muted, #64748b); }\n .ops-kpi strong.is-bad, .ops :global(.ops-num.is-bad) { color: #dc2626; }\n .ops-kpi strong.is-warn, .ops :global(.ops-num.is-warn) { color: #b45309; }\n .ops-kpi strong.is-bad small, .ops-kpi strong.is-warn small { color: inherit; opacity: 0.8; }\n\n .ops-stage { flex: 1 1 0; min-height: 0; padding: 8px; display: flex; flex-direction: column; }\n .ops-stage > :global(*) { flex: 1 1 0; min-height: 0; }\n .ops-pane { height: 100%; min-height: 0; display: flex; flex-direction: column; box-sizing: border-box; }\n .ops-pane > :global(.sv-grid-root-fill), .ops-pane > :global(*:only-child) { flex: 1 1 0; min-height: 0; }\n .ops-pane :global(.sv-grid-row) { cursor: pointer; }\n\n /* Service grid cells */\n .ops :global(.ops-svc) { display: inline-flex; align-items: center; gap: 9px; min-width: 0; }\n .ops :global(.ops-dot), .ops-dr :global(.ops-dot) { width: 8px; height: 8px; border-radius: 999px; flex: none; display: inline-block; }\n .ops :global(.ops-svc-txt) { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }\n .ops :global(.ops-svc-txt strong) { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }\n .ops :global(.ops-svc-txt span) { font-size: 11.5px; color: var(--sg-muted, #64748b); }\n .ops :global(.ops-pill), .ops-dr .ops-pill { display: inline-block; font-size: 11px; font-weight: 600; padding: 1px 8px; border-radius: 999px; color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); text-transform: capitalize; }\n .ops :global(.ops-num) { font-variant-numeric: tabular-nums; }\n .ops :global(.ops-row-sel > td) { background: var(--sg-selection-bg, #eff6ff); }\n\n /* Charts */\n .ops-chart-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px 0; flex: none; flex-wrap: wrap; }\n .ops-chart-head .ops-muted { font-size: 12px; }\n .ops-chart-body { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; padding: 4px 8px 8px; }\n .ops-chart-body > :global(*) { flex: 1 1 0; min-height: 0; }\n\n /* Incidents */\n .ops-inc { --sg-board-lane-w: 158px; }\n .ops-inc-bar { display: flex; align-items: center; gap: 10px; padding: 8px 10px; flex: none; }\n .ops-inc-bar .ops-muted { flex: 1; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n .ops-inc-grid { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }\n .ops-inc-grid > :global(*) { flex: 1 1 0; min-height: 0; }\n .ops-seg { display: inline-flex; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 7px; overflow: hidden; flex: none; }\n .ops-seg button { border: 0; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; font-size: 12px; padding: 4px 11px; cursor: pointer; }\n .ops-seg button + button { border-left: 1px solid var(--sg-border, #e5e7eb); }\n .ops-seg button.is-on { background: var(--c, var(--sg-fg, #0f172a)); color: var(--sg-bg, #fff); }\n .ops :global(.ops-card) { display: flex; flex-direction: column; gap: 6px; cursor: pointer; }\n .ops :global(.ops-card-top) { display: flex; align-items: center; gap: 6px; font-size: 11px; white-space: nowrap; }\n .ops :global(.ops-sev) { font-weight: 700; padding: 0 6px; border-radius: 4px; color: var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); }\n .ops :global(.ops-card-id) { color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums; }\n .ops :global(.ops-card-src) { display: inline-flex; margin-left: auto; color: var(--sg-muted, #64748b); }\n .ops :global(.ops-card-who) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }\n .ops :global(.ops-card-age) { margin-left: auto; color: var(--sg-muted, #64748b); }\n .ops :global(.ops-card-title) { font-weight: 600; font-size: 12.5px; line-height: 1.35; }\n .ops :global(.ops-card-foot) { display: flex; align-items: center; gap: 6px; font-size: 11.5px; color: var(--sg-muted, #64748b); }\n\n /* Lists: deploys, on-call, alert feed */\n .ops-list { overflow: auto; padding: 0 0 4px; }\n .ops-list-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 12px; border-bottom: 1px solid var(--sg-border, #e5e7eb); font-size: 12px; position: sticky; top: 0; background: var(--sg-bg, #fff); z-index: 1; }\n .ops-dep, .ops-alert { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-bottom: 1px solid var(--sg-border, #e5e7eb); }\n .ops-dep:hover, .ops-alert:hover { background: var(--sg-row-hover-bg, #f8fafc); }\n .ops-dep-ic { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 6px; flex: none; border: 1px solid var(--sg-border, #e5e7eb); color: var(--sg-muted, #64748b); }\n .ops-dep-ic.is-rb { color: #b45309; }\n .ops-dep-txt { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }\n .ops-dep-txt > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n .ops-dep-txt .ops-muted { font-size: 11.5px; }\n .ops-dep-msg { font-size: 12px; }\n .ops-dep-txt code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11px; }\n .ops-progress { display: block; height: 4px; margin-top: 4px; border-radius: 2px; background: var(--sg-border, #e5e7eb); overflow: hidden; }\n .ops-progress > span { display: block; height: 100%; background: var(--sg-accent, #2563eb); transition: width 1.2s linear; }\n .ops-alert.is-ack { opacity: 0.55; }\n .ops-tag { margin-left: 6px; font-size: 10.5px; padding: 0 6px; border-radius: 4px; border: 1px solid var(--sg-border, #e5e7eb); color: var(--sg-muted, #64748b); }\n .ops-tag-bad { color: #b91c1c; border-color: color-mix(in srgb, #dc2626 35%, transparent); }\n .ops-empty { padding: 18px 14px; color: var(--sg-muted, #64748b); font-size: 12.5px; line-height: 1.5; }\n\n /* Incident drawer (portalled, so its rules are not under .ops) */\n .ops-dr { display: flex; flex-direction: column; gap: 14px; font-size: 13px; color: var(--sg-fg, #0f172a); }\n .ops-dr .ops-muted { color: var(--sg-muted, #64748b); }\n .ops-dr-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }\n .ops-dr-label { width: 78px; font-size: 12px; font-weight: 600; color: var(--sg-muted, #64748b); }\n .ops-select { font: inherit; font-size: 13px; padding: 4px 8px; border: 1px solid var(--sg-input-border, var(--sg-border, #d1d5db)); border-radius: 6px; background: var(--sg-input-bg, var(--sg-bg, #fff)); color: var(--sg-fg, #0f172a); }\n .ops-dr-card { border: 1px solid var(--sg-border, #e5e7eb); border-radius: 10px; overflow: hidden; }\n .ops-dr-card-head { display: flex; align-items: center; gap: 8px; padding: 10px 12px; background: var(--sg-header-bg, #f8fafc); border-bottom: 1px solid var(--sg-border, #e5e7eb); }\n .ops-dr-card-head .ops-link { margin-left: auto; }\n .ops-dr-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); padding: 10px 12px; gap: 8px; font-variant-numeric: tabular-nums; }\n .ops-dr-stats span { display: flex; flex-direction: column; font-weight: 600; }\n .ops-dr-stats small { font-size: 11px; font-weight: 500; color: var(--sg-muted, #64748b); }\n .ops-dr-deploy { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--sg-border, #e5e7eb); font-size: 12px; }\n .ops-dr-h { margin: 4px 0 0; font-size: 13px; font-weight: 650; }\n .ops-tl { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; }\n .ops-tl-item { position: relative; display: flex; gap: 10px; padding: 0 0 12px; }\n .ops-tl-item:not(:last-child)::before { content: ''; position: absolute; left: 11px; top: 24px; bottom: 0; width: 1px; background: var(--sg-border, #e5e7eb); }\n .ops-tl-ic { display: grid; place-items: center; width: 23px; height: 23px; border-radius: 999px; flex: none; border: 1px solid var(--sg-border, #e5e7eb); background: var(--sg-bg, #fff); color: var(--sg-muted, #64748b); }\n /* Timeline kinds use the same status palette as the board. */\n .ops-tl-item[data-kind='open'] .ops-tl-ic { color: #dc2626; }\n .ops-tl-item[data-kind='ack'] .ops-tl-ic { color: #15803d; }\n .ops-tl-item[data-kind='deploy'] .ops-tl-ic { color: #b45309; }\n .ops-tl-item[data-kind='page'] .ops-tl-ic { color: #2563eb; }\n .ops-tl-txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; padding-top: 2px; }\n .ops-tl-txt .ops-muted { font-size: 11.5px; }\n .ops-note { display: flex; flex-direction: column; gap: 6px; align-items: flex-end; }\n .ops-note textarea { width: 100%; box-sizing: border-box; resize: vertical; font: inherit; font-size: 13px; padding: 7px 9px; border: 1px solid var(--sg-input-border, var(--sg-border, #d1d5db)); border-radius: 6px; background: var(--sg-input-bg, var(--sg-bg, #fff)); color: var(--sg-fg, #0f172a); }\n .ops-note textarea:focus { outline: none; border-color: var(--sg-accent, #2563eb); }\n .ops-dr-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; font-size: 12px; }\n .ops-dr .ops-btn, .ops-dr-foot .ops-btn { display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 11px; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 7px; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; font-size: 12.5px; cursor: pointer; }\n .ops-dr .ops-btn-sm { height: 26px; padding: 0 8px; font-size: 12px; }\n .ops-dr-foot .ops-btn-primary { background: var(--c); border-color: var(--c); color: #fff; }\n .ops-dr .ops-btn:disabled { opacity: 0.45; cursor: default; }\n .ops-dr .ops-link { border: 0; background: none; padding: 0; font: inherit; font-size: 12px; font-weight: 600; color: var(--sg-accent, #2563eb); cursor: pointer; }\n .ops-dr .ops-seg { display: inline-flex; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 7px; overflow: hidden; }\n .ops-dr .ops-seg button { border: 0; background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a); font: inherit; font-size: 12px; padding: 4px 10px; cursor: pointer; }\n .ops-dr .ops-seg button + button { border-left: 1px solid var(--sg-border, #e5e7eb); }\n .ops-dr .ops-seg button.is-on { background: var(--c, var(--sg-fg, #0f172a)); color: var(--sg-bg, #fff); }\n\n @media (max-width: 900px) {\n .ops-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }\n .ops-kpi:nth-child(3) { border-right: 0; }\n .ops-kpi:nth-child(-n + 3) { border-bottom: 1px solid var(--sg-border, #e5e7eb); }\n }\n @media (max-width: 639px), (max-height: 500px) and (pointer: coarse) {\n .ops { flex-shrink: 0; min-height: 900px; }\n .ops-dr-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n }\n</style>\n"
3057
3057
  },
3058
+ {
3059
+ "id": "503-big-data",
3060
+ "path": "examples/src/demos/503-big-data.svelte",
3061
+ "title": "Big Data",
3062
+ "category": "Server-Side Row Model",
3063
+ "blurb": "503. Big data: up to 10,000,000 rows x 10,000 columns One SvGrid on the Enterprise server row model. The rows live behind a Web Worker that answers the model's requests like a database would: it sorts",
3064
+ "source": "<script lang=\"ts\">\n /**\n * 503. Big data: up to 10,000,000 rows x 10,000 columns\n * ------------------------------------------------------\n * One SvGrid on the Enterprise server row model. The rows live behind a Web\n * Worker that answers the model's requests like a database would: it sorts\n * (radix, exact), filters, groups and applies edits over a generated\n * dataset, and sends back only the ids of the rows asked for. The page\n * builds just the rows on screen, so the grid's memory and per-scroll work\n * do not grow with the row count.\n *\n * Pick a size, then sort a column, type in a filter, group, or edit a cell\n * (balance, rating, status, game, bought, the months and the metrics are\n * editable). \"Run benchmark\" times each of those against the current size\n * and lists the results. Every number on the page is measured live in this\n * browser.\n */\n import { onDestroy, onMount } from 'svelte'\n import {\n SvGrid,\n renderComponent,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n type ColumnDef,\n type ConditionalFormat,\n type SvGridApi,\n } from '@svgrid/grid'\n import { createServerRowModel, serverGroupText, SvGroupCell, type ServerRowModelGridRow } from '@svgrid/enterprise'\n import { createBigDataClient, type Activity, type BigRow } from '../shared/bigdata/client'\n import {\n columnsFor,\n COUNTRY_BY_NAME,\n CONTINENTS,\n GAMES,\n LANGUAGES,\n STATUSES,\n type BigColumn,\n } from '../shared/bigdata/data'\n\n // What the grid holds: a data row or a group row, each carrying __group.\n type GridRow = ServerRowModelGridRow<BigRow>\n\n const ROW_OPTIONS = [10_000, 100_000, 1_000_000, 10_000_000]\n const COL_OPTIONS = [10, 25, 100, 1_000, 10_000]\n const GROUPINGS: Array<{ label: string; by: string[] }> = [\n { label: 'None', by: [] },\n { label: 'Continent > Country', by: ['continent', 'country'] },\n { label: 'Country', by: ['country'] },\n { label: 'Game', by: ['game'] },\n { label: 'Status', by: ['status'] },\n { label: 'Rating', by: ['rating'] },\n ]\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n const compact = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 })\n const full = new Intl.NumberFormat('en-US')\n const usd = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })\n const fmtMs = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)} s` : ms >= 10 ? `${Math.round(ms)} ms` : `${ms.toFixed(1)} ms`)\n const sizeLabel = (n: number) => (n >= 1_000_000 ? `${n / 1_000_000}M` : n >= 1_000 ? `${n / 1_000}K` : String(n))\n\n let rowCount = $state(1_000_000)\n let colCount = $state(25)\n let grouping = $state(0)\n let search = $state('')\n let activity = $state<Activity>({})\n let ctl = $state<ReturnType<typeof createServerRowModel<BigRow>> | null>(null)\n let columns = $state<ColumnDef<any, GridRow>[]>([])\n let pinning = $state<{ left: string[]; right: string[] }>({ left: [], right: [] })\n let api: SvGridApi<any, GridRow> | null = null\n let gridKey = $state(0)\n let readyMs = $state<number | null>(null)\n let editNote = $state('')\n\n const client = createBigDataClient((a) => (activity = a))\n\n // ---- columns ---------------------------------------------------------------\n\n const chip = (labels: readonly string[]) => ({ getValue }: { getValue: () => unknown }) => {\n const value = getValue()\n const i = labels.indexOf(String(value))\n return i < 0 ? 'bd-chip' : `bd-chip bd-c${i % 8}`\n }\n const moneyClass = ({ getValue }: { getValue: () => unknown }) => {\n const value = getValue()\n const n = Number(value)\n if (!Number.isFinite(n)) return ''\n return n < 0 ? 'bd-neg' : n >= 15_000 ? 'bd-pos3' : n >= 8_000 ? 'bd-pos2' : n >= 2_000 ? 'bd-pos1' : ''\n }\n const heatClass = ({ getValue }: { getValue: () => unknown }) => {\n const value = getValue()\n const n = Number(value)\n return Number.isFinite(n) ? `bd-h${Math.min(9, Math.max(0, Math.floor(n / 100)))}` : ''\n }\n const stars = (v: unknown) => {\n const n = Math.round(Number(v))\n return Number.isFinite(n) && n >= 1 ? '★'.repeat(Math.min(5, n)) + '☆'.repeat(Math.max(0, 5 - n)) : String(v ?? '')\n }\n\n /** A data row, as opposed to a group row of the server row model. */\n const isLeaf = (row: { original?: unknown } | undefined) =>\n (row?.original as { __group?: { kind?: string } } | undefined)?.__group?.kind === 'leaf'\n /** Format data rows; a group row has no value in this column, so show nothing. */\n const leafOnly =\n (fmt: (value: unknown) => string) =>\n ({ value, row }: { value: unknown; row: { original?: unknown } }) =>\n isLeaf(row) ? fmt(value) : ''\n const editableLeaf = (ctx: { row: { original?: unknown } }) => isLeaf(ctx.row)\n\n function toColumnDef(c: BigColumn): ColumnDef<any, GridRow> {\n const base = { field: c.field, header: c.header, width: c.width } as ColumnDef<any, GridRow>\n switch (c.field) {\n case 'id':\n return { ...base, align: 'right', cellClass: 'bd-id', editable: false, formatter: leafOnly((v) => full.format(Number(v))) }\n case 'name':\n // The group column too: a group row shows its chevron, label and row\n // count here; a data row shows the person's name.\n return {\n id: 'name',\n header: 'Name',\n width: 250,\n editable: false,\n cellClass: 'bd-name',\n fieldFn: (row) => serverGroupText(row as never, 'name'),\n cell: (ctx) =>\n renderComponent(SvGroupCell, {\n row: ctx.row.original as never,\n onToggle: () => ctl?.group.onToggle(ctx.row.original as never),\n leafField: 'name',\n }),\n }\n case 'email':\n return { ...base, cellClass: 'bd-muted', editable: false, formatter: leafOnly((v) => String(v)) }\n case 'country':\n return { ...base, editable: false, formatter: leafOnly((v) => `${COUNTRY_BY_NAME.get(String(v))?.flag ?? ''} ${String(v)}`) }\n case 'language':\n return { ...base, editable: false, cellClass: chip(LANGUAGES), formatter: leafOnly((v) => String(v)) }\n case 'continent':\n return { ...base, editable: false, cellClass: chip(CONTINENTS), formatter: leafOnly((v) => String(v)) }\n case 'game':\n return { ...base, cellClass: chip(GAMES), editable: editableLeaf, editorType: 'select', editorOptions: [...GAMES], formatter: leafOnly((v) => String(v)) }\n case 'bought':\n // A boolean renders as a checkbox on data rows; group rows have none.\n return { ...base, align: 'center', editable: editableLeaf, formatter: leafOnly((v) => (v ? 'Yes' : 'No')) }\n case 'rating':\n return {\n ...base,\n cellClass: 'bd-stars',\n editable: editableLeaf,\n editorType: 'number',\n // On a group row the value is the average rating.\n formatter: ({ value, row }) => (isLeaf(row) ? stars(value) : value == null ? '' : `avg ${Number(value).toFixed(1)}`),\n }\n case 'balance':\n return { ...base, align: 'right', editable: editableLeaf, editorType: 'number', formatter: ({ value }) => (value == null ? '' : usd.format(Number(value))) }\n case 'status':\n return { ...base, editable: editableLeaf, editorType: 'select', editorOptions: [...STATUSES], cellClass: ({ getValue }) => { const value = getValue(); return value ? `bd-pill bd-s-${String(value).toLowerCase()}` : '' }, formatter: leafOnly((v) => String(v)) }\n case 'joined':\n return { ...base, editable: false, cellClass: 'bd-muted', formatter: leafOnly((v) => String(v)) }\n case 'total':\n return { ...base, align: 'right', editable: false, cellClass: 'bd-total', formatter: ({ value }) => (value == null ? '' : usd.format(Number(value))) }\n default:\n if (c.kind === 'money') {\n return { ...base, align: 'right', editable: editableLeaf, editorType: 'number', cellClass: moneyClass, formatter: leafOnly((v) => usd.format(Number(v))) }\n }\n // A metric is not on the row object: read it from the row index, so\n // 10,000 columns cost nothing until a cell is on screen.\n return {\n id: c.field,\n header: c.header,\n width: c.width,\n fieldFn: (row) => client.cell(row, c.field),\n align: 'right',\n editable: editableLeaf,\n editorType: 'number',\n cellClass: heatClass,\n formatter: ({ value }) => (value == null ? '' : Number(value).toFixed(2)),\n }\n }\n }\n\n // Data bars with fixed ranges: the grid never scans the 10M rows for a min\n // and max, it scales each cell against the range the data is drawn from.\n const conditionalFormats: ConditionalFormat<GridRow>[] = [\n { type: 'dataBar', columns: ['balance'], color: '#3b82f6', gradient: true, minValue: 0, maxValue: 999_999 },\n { type: 'dataBar', columns: ['total'], color: '#10b981', negativeColor: '#ef4444', minValue: -60_000, maxValue: 240_000 },\n ]\n\n // ---- the dataset -----------------------------------------------------------\n\n async function rebuild() {\n readyMs = null\n editNote = ''\n const t0 = performance.now()\n ctl?.dispose()\n ctl = null\n await client.configure(rowCount, colCount)\n const cols = columnsFor(colCount)\n columns = cols.map(toColumnDef)\n const fields = new Set(cols.map((c) => c.field))\n pinning = {\n left: fields.has('name') ? ['name'] : [],\n right: fields.has('total') ? ['total'] : [],\n }\n ctl = createServerRowModel<BigRow>(client.source, {\n blockSize: 100,\n maxBlocksInCache: 80,\n blockLoadDebounceMs: 30,\n keepRowsWhileLoading: 800,\n groupBy: GROUPINGS[grouping]!.by.filter((f) => fields.has(f)),\n aggregations: [\n { col: 'balance', fn: 'sum' },\n { col: 'total', fn: 'sum' },\n { col: 'rating', fn: 'avg' },\n ],\n childCount: (r) => Number((r as Record<string, unknown>).__count) || undefined,\n getRowId: (r) => String(r.id),\n })\n gridKey += 1\n await waitPainted()\n readyMs = performance.now() - t0\n }\n\n function setGrouping(i: number) {\n grouping = i\n ctl?.setGroupBy(GROUPINGS[i]!.by.filter((f) => columns.some((c) => c.field === f)))\n }\n\n let searchTimer: ReturnType<typeof setTimeout> | null = null\n function onSearch(value: string) {\n search = value\n if (searchTimer) clearTimeout(searchTimer)\n searchTimer = setTimeout(() => {\n if (ctl) ctl.setFilter({ ...ctl.getState().filterModel, global: value })\n }, 250)\n }\n\n async function onEdit(e: { row: GridRow; columnId: string; newValue: unknown; oldValue: unknown }) {\n const meta = (e.row as Record<string, unknown>).__group as { kind?: string } | undefined\n if (meta && meta.kind !== 'leaf') return\n if (Object.is(e.newValue, e.oldValue)) return\n const value = typeof e.oldValue === 'number' ? Number(e.newValue) : e.newValue\n await ctl?.updateRow(String(e.row.id), { [e.columnId]: value } as Partial<BigRow>)\n const r = activity.edit\n editNote = r ? `Saved in ${fmtMs(r.ms)} in the worker; ${r.patchedViews} cached view${r.patchedViews === 1 ? '' : 's'} patched in place` : ''\n }\n\n // ---- measuring -------------------------------------------------------------\n\n /** Resolve once the rows on screen are real rows, not loading placeholders. */\n function waitPainted(timeout = 180_000): Promise<void> {\n const start = performance.now()\n return new Promise((resolve) => {\n const check = () => {\n const host = document.querySelector('.bd-grid')\n const rows = host ? [...host.querySelectorAll('tbody tr')] : []\n const busy = rows.some((r) => r.getAttribute('aria-busy') === 'true')\n const loading = !!host?.querySelector('.sv-grid-loading-overlay, .sv-grid-skeleton-row')\n if ((rows.length > 0 && !busy && !loading) || performance.now() - start > timeout) {\n requestAnimationFrame(() => requestAnimationFrame(() => resolve()))\n return\n }\n requestAnimationFrame(check)\n }\n requestAnimationFrame(check)\n })\n }\n\n // Frame meter: frames per second and the longest frame in the last second.\n let fps = $state(0)\n let longest = $state(0)\n let domCells = $state(0)\n let heapMb = $state<number | null>(null)\n let raf = 0\n onMount(() => {\n let frames = 0\n let worst = 0\n let last = performance.now()\n let windowStart = last\n const loop = (t: number) => {\n frames += 1\n worst = Math.max(worst, t - last)\n last = t\n if (t - windowStart >= 1000) {\n fps = Math.round((frames * 1000) / (t - windowStart))\n longest = worst\n frames = 0\n worst = 0\n windowStart = t\n domCells = document.querySelectorAll('.bd-grid td').length\n const mem = (performance as unknown as { memory?: { usedJSHeapSize: number } }).memory\n heapMb = mem ? Math.round(mem.usedJSHeapSize / 1e6) : null\n }\n raf = requestAnimationFrame(loop)\n }\n raf = requestAnimationFrame(loop)\n void rebuild()\n })\n onDestroy(() => {\n cancelAnimationFrame(raf)\n ctl?.dispose()\n client.dispose()\n })\n\n // ---- benchmark -----------------------------------------------------------------\n\n type BenchRow = { step: string; ms: number; worker?: number; note: string }\n let bench = $state<BenchRow[]>([])\n let benchRunning = $state(false)\n\n /**\n * Resolve when the step is really done: no request is waiting on the worker\n * and the rows on screen are real, for four frames running (so a debounced\n * request has had its chance to start). Returns the time of the first of\n * those frames, so the wait itself is not counted.\n */\n function waitSettled(timeout = 600_000): Promise<number> {\n const start = performance.now()\n let quietSince = 0\n let quietFrames = 0\n return new Promise((resolve) => {\n const check = (t: number) => {\n const host = document.querySelector('.bd-grid')\n const rows = host ? [...host.querySelectorAll('tbody tr')] : []\n const busy = rows.some((r) => r.getAttribute('aria-busy') === 'true')\n const quiet = client.pending() === 0 && rows.length > 0 && !busy\n if (quiet) {\n if (quietFrames === 0) quietSince = t\n quietFrames += 1\n } else {\n quietFrames = 0\n }\n if (quietFrames >= 4 || t - start > timeout) {\n resolve(quietFrames >= 4 ? quietSince : t)\n return\n }\n requestAnimationFrame(check)\n }\n requestAnimationFrame(check)\n })\n }\n\n async function timed(step: string, note: string, action: () => unknown): Promise<void> {\n const seq = activity.compute?.seq ?? 0\n const t0 = performance.now()\n await action()\n const settled = await waitSettled()\n const ms = settled - t0\n // The worker's compute time, if this step made the worker compute anything.\n const w = activity.compute\n bench = [...bench, { step, ms, worker: w && w.seq > seq ? w.ms : undefined, note }]\n }\n\n function scroller(): HTMLElement | null {\n return document.querySelector('.bd-grid .sv-grid-container')\n }\n\n async function runBenchmark() {\n if (benchRunning) return\n benchRunning = true\n bench = []\n grouping = 0\n search = ''\n try {\n await timed('Load the dataset', `${full.format(rowCount)} rows x ${full.format(colCount)} columns`, () => rebuild())\n await timed('Jump to the middle', `row ${full.format(Math.floor(rowCount / 2))}`, () => {\n const s = scroller()\n if (s) s.scrollTop = (s.scrollHeight - s.clientHeight) / 2\n })\n await timed('Jump to the last row', `row ${full.format(rowCount)}`, () => {\n const s = scroller()\n if (s) s.scrollTop = s.scrollHeight\n })\n if (colCount >= 100) {\n await timed('Scroll to the last column', `${full.format(colCount)} columns`, () => {\n const s = scroller()\n if (s) s.scrollLeft = s.scrollWidth\n })\n }\n await timed('Sort by bank balance', 'radix sort, every row', () => api?.setSort('balance', 'desc'))\n await timed('Filter: country is Japan', 'one pass over every row', () =>\n api?.setFilter('country', { operator: 'equals', value: 'Japan' } as never),\n )\n await timed('Clear the filter', 'back to the sorted view, cached', () => api?.clearAllFilters?.())\n await timed('Group by continent and country', 'counts and sums in one pass', () => setGrouping(1))\n await timed('Expand Europe', 'the first level of children', () => ctl?.expandGroup(['Europe']))\n await timed('Ungroup', '', () => setGrouping(0))\n } finally {\n benchRunning = false\n }\n }\n\n const cells = $derived(rowCount * colCount)\n const opLabel: Record<string, string> = {\n identity: 'Served in id order',\n cached: 'Served from the cached view',\n scan: 'Filtered',\n sort: 'Sorted',\n 'scan+sort': 'Filtered and sorted',\n group: 'Grouped',\n }\n</script>\n\n<section class=\"bd-wrap\">\n <div class=\"bd-controls\">\n <div class=\"bd-field\">\n <span class=\"bd-label\">Rows</span>\n <div class=\"bd-seg\" role=\"radiogroup\" aria-label=\"Rows\">\n {#each ROW_OPTIONS as n (n)}\n <button role=\"radio\" aria-checked={rowCount === n} class:on={rowCount === n} disabled={benchRunning} onclick={() => { rowCount = n; void rebuild() }}>{sizeLabel(n)}</button>\n {/each}\n </div>\n </div>\n <div class=\"bd-field\">\n <span class=\"bd-label\">Columns</span>\n <div class=\"bd-seg\" role=\"radiogroup\" aria-label=\"Columns\">\n {#each COL_OPTIONS as n (n)}\n <button role=\"radio\" aria-checked={colCount === n} class:on={colCount === n} disabled={benchRunning} onclick={() => { colCount = n; void rebuild() }}>{sizeLabel(n)}</button>\n {/each}\n </div>\n </div>\n <div class=\"bd-field\">\n <span class=\"bd-label\">Group by</span>\n <select value={grouping} disabled={benchRunning} onchange={(e) => setGrouping(Number(e.currentTarget.value))}>\n {#each GROUPINGS as g, i (g.label)}<option value={i}>{g.label}</option>{/each}\n </select>\n </div>\n <div class=\"bd-field bd-grow\">\n <span class=\"bd-label\">Search</span>\n <input type=\"search\" placeholder=\"name, email, country, game, status\" value={search} oninput={(e) => onSearch(e.currentTarget.value)} />\n </div>\n <button class=\"bd-run\" onclick={runBenchmark} disabled={benchRunning}>{benchRunning ? 'Running...' : 'Run benchmark'}</button>\n </div>\n\n <div class=\"bd-stats\">\n <div class=\"bd-stat\"><span class=\"k\">Rows</span><span class=\"v\">{full.format(rowCount)}</span></div>\n <div class=\"bd-stat\"><span class=\"k\">Columns</span><span class=\"v\">{full.format(colCount)}</span></div>\n <div class=\"bd-stat\"><span class=\"k\">Cells</span><span class=\"v\">{compact.format(cells)}</span></div>\n <div class=\"bd-stat\"><span class=\"k\">Cells in the DOM</span><span class=\"v\">{full.format(domCells)}</span></div>\n <div class=\"bd-stat\"><span class=\"k\">Frame rate</span><span class=\"v\">{fps} fps</span><span class=\"s\">longest frame {fmtMs(longest)}</span></div>\n {#if heapMb != null}<div class=\"bd-stat\"><span class=\"k\">JS heap</span><span class=\"v\">{heapMb} MB</span></div>{/if}\n <div class=\"bd-stat bd-wide\">\n <span class=\"k\">Last query</span>\n {#if activity.progress}\n <span class=\"v\">{activity.progress.phase}...</span>\n <span class=\"bd-bar\"><span style={`width:${Math.round((activity.progress.done / Math.max(1, activity.progress.total)) * 100)}%`}></span></span>\n {:else if activity.last}\n <span class=\"v\">{fmtMs(activity.last.ms)}</span>\n <span class=\"s\">{opLabel[activity.last.op] ?? activity.last.op}{activity.last.scanned ? `, ${compact.format(activity.last.scanned)} rows read` : ''} - worker time</span>\n {:else}\n <span class=\"v\">-</span>\n {/if}\n </div>\n {#if readyMs != null}<div class=\"bd-stat\"><span class=\"k\">Ready in</span><span class=\"v\">{fmtMs(readyMs)}</span></div>{/if}\n </div>\n\n {#if editNote}<p class=\"bd-note\">{editNote}</p>{/if}\n\n {#if bench.length}\n <div class=\"bd-bench\">\n <table>\n <thead><tr><th>Step</th><th>On screen</th><th>Worker</th><th></th></tr></thead>\n <tbody>\n {#each bench as b (b.step)}\n <tr><td>{b.step}</td><td class=\"num\">{fmtMs(b.ms)}</td><td class=\"num\">{b.worker != null ? fmtMs(b.worker) : '-'}</td><td class=\"dim\">{b.note}</td></tr>\n {/each}\n </tbody>\n </table>\n </div>\n {/if}\n\n <div class=\"bd-grid\">\n {#if ctl}\n {#key gridKey}\n <SvGrid\n rowModel={ctl}\n {columns}\n {features}\n {conditionalFormats}\n initialColumnPinning={pinning}\n sortable\n filterable\n filterMode=\"menu\"\n editable\n columnResize\n stickyGroupRows\n rowHeight={34}\n containerHeight=\"100%\"\n onCellValueChange={onEdit}\n onApiReady={(next) => (api = next)}\n />\n {/key}\n {/if}\n </div>\n</section>\n\n<style>\n .bd-wrap {\n flex: 1;\n min-height: 0;\n display: flex;\n flex-direction: column;\n gap: 10px;\n color: var(--sg-fg, #0f172a);\n }\n .bd-controls {\n display: flex;\n flex-wrap: wrap;\n align-items: flex-end;\n gap: 12px;\n }\n .bd-field { display: flex; flex-direction: column; gap: 4px; }\n .bd-grow { flex: 1 1 200px; }\n .bd-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sg-muted, #64748b); }\n .bd-seg {\n display: inline-flex;\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 8px;\n overflow: hidden;\n }\n .bd-seg button {\n font: inherit;\n font-size: 13px;\n padding: 6px 11px;\n border: 0;\n border-right: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n cursor: pointer;\n font-variant-numeric: tabular-nums;\n }\n .bd-seg button:last-child { border-right: 0; }\n .bd-seg button.on { background: var(--sg-fg, #0f172a); color: var(--sg-bg, #fff); font-weight: 600; }\n .bd-seg button:disabled { cursor: default; opacity: 0.6; }\n .bd-controls select,\n .bd-controls input {\n font: inherit;\n font-size: 13px;\n padding: 6px 10px;\n border-radius: 8px;\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n }\n .bd-run {\n font: inherit;\n font-weight: 600;\n font-size: 13px;\n padding: 7px 14px;\n border-radius: 8px;\n border: 1px solid var(--sg-fg, #0f172a);\n background: var(--sg-fg, #0f172a);\n color: var(--sg-bg, #fff);\n cursor: pointer;\n }\n .bd-run:disabled { opacity: 0.6; cursor: default; }\n .bd-seg button:focus-visible,\n .bd-run:focus-visible,\n .bd-controls select:focus-visible,\n .bd-controls input:focus-visible { outline: 2px solid var(--sg-accent, #ea580c); outline-offset: 1px; }\n\n .bd-stats { display: flex; flex-wrap: wrap; gap: 8px; }\n .bd-stat {\n display: flex;\n flex-direction: column;\n min-width: 120px;\n padding: 8px 12px;\n border: 1px solid var(--sg-border, #e2e8f0);\n border-radius: 10px;\n background: var(--sg-bg, #fff);\n }\n .bd-stat.bd-wide { min-width: 260px; flex: 1 1 260px; }\n .bd-stat .k { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sg-muted, #64748b); }\n .bd-stat .v { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.25; }\n .bd-stat .s { font-size: 12px; color: var(--sg-muted, #64748b); }\n .bd-bar { display: block; height: 6px; margin-top: 6px; border-radius: 3px; background: var(--sg-header-bg, #f1f5f9); overflow: hidden; }\n .bd-bar > span { display: block; height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); transition: width 120ms linear; }\n .bd-note { margin: 0; font-size: 13px; color: var(--sg-muted, #64748b); }\n\n .bd-bench { overflow-x: auto; }\n .bd-bench table { border-collapse: collapse; font-size: 13px; font-variant-numeric: tabular-nums; }\n .bd-bench th, .bd-bench td { padding: 4px 12px; border-bottom: 1px solid var(--sg-border, #e2e8f0); text-align: left; white-space: nowrap; }\n .bd-bench th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--sg-muted, #64748b); font-weight: 600; }\n .bd-bench td.num { text-align: right; font-weight: 600; }\n .bd-bench td.dim { color: var(--sg-muted, #64748b); }\n\n .bd-grid { flex: 1; min-height: 320px; display: flex; flex-direction: column; }\n\n /* ---- cell palette (data colours, not chrome) ------------------------------- */\n .bd-grid :global(.bd-id) { color: var(--sg-muted, #64748b); font-variant-numeric: tabular-nums; }\n .bd-grid :global(.bd-muted) { color: var(--sg-muted, #64748b); }\n .bd-grid :global(.bd-name) { font-weight: 600; }\n .bd-grid :global(.bd-total) { font-weight: 700; }\n .bd-grid :global(.bd-stars) { color: #f59e0b; letter-spacing: 1px; }\n .bd-grid :global(.bd-yes) { color: #10b981; font-weight: 700; }\n .bd-grid :global(.bd-no) { color: var(--sg-muted, #94a3b8); }\n .bd-grid :global(.bd-neg) { color: #ef4444; }\n .bd-grid :global(.bd-pos1) { color: #16a34a; }\n .bd-grid :global(.bd-pos2) { color: #15803d; font-weight: 600; }\n .bd-grid :global(.bd-pos3) { color: #047857; font-weight: 700; }\n .bd-grid :global(.bd-chip),\n .bd-grid :global(.bd-pill) { font-weight: 600; font-size: 12px; }\n .bd-grid :global(.bd-c0) { color: #2563eb; }\n .bd-grid :global(.bd-c1) { color: #9333ea; }\n .bd-grid :global(.bd-c2) { color: #db2777; }\n .bd-grid :global(.bd-c3) { color: #ea580c; }\n .bd-grid :global(.bd-c4) { color: #0891b2; }\n .bd-grid :global(.bd-c5) { color: #65a30d; }\n .bd-grid :global(.bd-c6) { color: #ca8a04; }\n .bd-grid :global(.bd-c7) { color: #4f46e5; }\n .bd-grid :global(.bd-s-active) { color: #16a34a; }\n .bd-grid :global(.bd-s-churned) { color: #dc2626; }\n .bd-grid :global(.bd-s-pending) { color: #d97706; }\n .bd-grid :global(.bd-s-vip) { color: #7c3aed; }\n .bd-grid :global(.bd-h0) { background: color-mix(in srgb, #8b5cf6 4%, transparent); }\n .bd-grid :global(.bd-h1) { background: color-mix(in srgb, #8b5cf6 8%, transparent); }\n .bd-grid :global(.bd-h2) { background: color-mix(in srgb, #8b5cf6 12%, transparent); }\n .bd-grid :global(.bd-h3) { background: color-mix(in srgb, #8b5cf6 16%, transparent); }\n .bd-grid :global(.bd-h4) { background: color-mix(in srgb, #8b5cf6 20%, transparent); }\n .bd-grid :global(.bd-h5) { background: color-mix(in srgb, #ec4899 18%, transparent); }\n .bd-grid :global(.bd-h6) { background: color-mix(in srgb, #ec4899 24%, transparent); }\n .bd-grid :global(.bd-h7) { background: color-mix(in srgb, #f97316 24%, transparent); }\n .bd-grid :global(.bd-h8) { background: color-mix(in srgb, #f97316 32%, transparent); }\n .bd-grid :global(.bd-h9) { background: color-mix(in srgb, #ef4444 34%, transparent); }\n\n @media (max-width: 640px) {\n .bd-stat { min-width: calc(50% - 4px); }\n .bd-seg button { padding: 6px 8px; }\n }\n</style>\n"
3065
+ },
3058
3066
  {
3059
3067
  "id": "51-ai-assistant",
3060
3068
  "path": "examples/src/demos/51-ai-assistant.svelte",
@@ -3261,7 +3269,7 @@ export const examples = [
3261
3269
  "title": "Million Rows",
3262
3270
  "category": "Getting Started",
3263
3271
  "blurb": "78. One million rows Row + column virtualization carrying a literal 1,000,000-row dataset with sort, filter, group, scroll, and inline edit all enabled.",
3264
- "source": "<script lang=\"ts\">\n /**\n * 78. One million rows\n * --------------------\n * Row + column virtualization carrying a literal 1,000,000-row dataset\n * with sort, filter, group, scroll, and inline edit all enabled.\n *\n * Generation is chunked through requestAnimationFrame so the UI keeps\n * painting while the heap fills. The row shape is intentionally lean\n * (9 fields) - at 1M rows that's the difference between ~250 MB and\n * an unresponsive tab.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n type GridColumns,\n type SvGridApi,\n } from '@svgrid/grid'\n\n type Status = 'active' | 'pending' | 'inactive'\n\n type MillionRow = {\n id: string\n firstName: string\n lastName: string\n department: string\n country: string\n status: Status\n age: number\n salary: number\n performance: number\n }\n\n // --- Compact pools (kept short on purpose: smaller string-intern table\n // means 1M rows share a tiny set of pointers). ------------------------\n const FIRST = ['Ada','Linus','Grace','Alan','Margaret','Donald','Barbara','John',\n 'Edsger','Niklaus','Anders','Brendan','Yukihiro','Rasmus','Bjarne','Tim',\n 'Guido','Larry','James','Brian','Dennis','Ken','Mary','Susan','Karen','Lisa']\n const LAST = ['Lovelace','Torvalds','Hopper','Turing','Hamilton','Knuth','Liskov',\n 'McCarthy','Dijkstra','Wirth','Hejlsberg','Eich','Matsumoto','Lerdorf',\n 'Stroustrup','van Rossum','Wall','Gosling','Kernighan','Ritchie','Thompson']\n const DEPARTMENTS = ['Engineering','Design','Product','Sales','Support','Operations']\n const COUNTRIES = ['US','UK','DE','FR','JP','BR','IN','AU','CA','NL']\n const STATUSES: readonly Status[] = ['active','pending','inactive'] as const\n\n // --- Deterministic PRNG (Mulberry32) ------------------------------------\n function rng(seed: number) {\n let t = seed >>> 0\n return () => {\n t = (t + 0x6d2b79f5) >>> 0\n let x = t\n x = Math.imul(x ^ (x >>> 15), x | 1)\n x ^= x + Math.imul(x ^ (x >>> 7), x | 61)\n return ((x ^ (x >>> 14)) >>> 0) / 4_294_967_296\n }\n }\n\n const TARGET = 1_000_000\n const CHUNK = 50_000\n /**\n * Row height (px). 1M × 32 = 32M pixels, which exceeds Firefox's\n * element-height cap (~17.9M) and lands at Chrome's edge (~33.5M).\n * 18px keeps the total scroll height at 18M so EVERY row is reachable\n * in every browser. The grid still reads cleanly at this density.\n */\n const ROW_H = 18\n\n let rows = $state.raw<MillionRow[]>([])\n let generated = $state(0)\n let busy = $state(true)\n let buildMs = $state(0)\n\n function buildChunk(rand: () => number, into: MillionRow[], from: number, to: number) {\n const firstLen = FIRST.length\n const lastLen = LAST.length\n const deptLen = DEPARTMENTS.length\n const countryLen = COUNTRIES.length\n const statusLen = STATUSES.length\n for (let i = from; i < to; i++) {\n const dept = DEPARTMENTS[Math.floor(rand() * deptLen)]!\n const country = COUNTRIES[Math.floor(rand() * countryLen)]!\n const status = STATUSES[Math.floor(rand() * statusLen)]!\n into[i] = {\n id: 'r_' + i.toString(36),\n firstName: FIRST[Math.floor(rand() * firstLen)]!,\n lastName: LAST [Math.floor(rand() * lastLen)]!,\n department: dept,\n country,\n status,\n age: 21 + Math.floor(rand() * 45),\n salary: 35_000 + Math.floor(rand() * 165_000),\n performance: Math.floor(rand() * 100),\n }\n }\n }\n\n async function buildAll() {\n busy = true\n generated = 0\n const t0 = performance.now()\n const rand = rng(1337)\n const buf: MillionRow[] = new Array(TARGET)\n let cursor = 0\n while (cursor < TARGET) {\n const next = Math.min(cursor + CHUNK, TARGET)\n buildChunk(rand, buf, cursor, next)\n cursor = next\n generated = cursor\n // Yield to the browser so the progress bar paints and the page stays\n // responsive. Without this the JS thread runs solid for the full build.\n await new Promise<void>((r) => requestAnimationFrame(() => r()))\n }\n rows = buf\n buildMs = Math.round(performance.now() - t0)\n busy = false\n }\n\n $effect(() => {\n if (rows.length === 0 && busy) {\n void buildAll()\n }\n })\n\n // --- Grid configuration -------------------------------------------------\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n const columns: GridColumns<MillionRow> = [\n { field: 'id', header: 'ID', editorType: 'text', width: 110, editable: false },\n { field: 'firstName', header: 'First name', editorType: 'text', width: 130 },\n { field: 'lastName', header: 'Last name', editorType: 'text', width: 140 },\n { field: 'department', header: 'Department', editorType: 'list',\n editorOptions: DEPARTMENTS as unknown as ReadonlyArray<string>, width: 140 },\n { field: 'country', header: 'Country', editorType: 'list',\n editorOptions: COUNTRIES as unknown as ReadonlyArray<string>, width: 100 },\n { field: 'status', header: 'Status', editorType: 'list',\n editorOptions: STATUSES as unknown as ReadonlyArray<string>, width: 110 },\n { field: 'age', header: 'Age', editorType: 'number', width: 90 },\n { field: 'salary', header: 'Salary', editorType: 'number', width: 130,\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },\n { field: 'performance',header: 'Performance',editorType: 'number', width: 130 },\n ]\n\n let api = $state<SvGridApi<typeof features, MillionRow> | null>(null)\n let groupBy = $state<string[]>([])\n let editing = $state(true)\n\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n\n const pct = $derived(Math.min(100, Math.round((generated / TARGET) * 100)))\n const fmt = new Intl.NumberFormat('en-US')\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <!-- KPI strip -->\n <div class=\"mr-kpi-strip shrink-0\">\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Rows</div>\n <div class=\"mr-kpi-value tabular-nums\">{fmt.format(rows.length || generated)}</div>\n <div class=\"mr-kpi-foot\">target {fmt.format(TARGET)}</div>\n </div>\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Columns</div>\n <div class=\"mr-kpi-value tabular-nums\">{columns.length}</div>\n <div class=\"mr-kpi-foot\">column virtualization on</div>\n </div>\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Build time</div>\n <div class=\"mr-kpi-value tabular-nums\">{busy ? '…' : buildMs + ' ms'}</div>\n <div class=\"mr-kpi-foot\">chunked, 50k / frame</div>\n </div>\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Editing</div>\n <div class={`mr-kpi-value ${editing ? 'mr-up' : 'mr-down'}`}>{editing ? 'ON' : 'off'}</div>\n <div class=\"mr-kpi-foot\">double-click any cell</div>\n </div>\n </div>\n\n <!-- Toolbar -->\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <span class=\"font-medium\">Group by:</span>\n <button type=\"button\" onclick={() => applyGroup([])}\n class=\"mr-btn {groupBy.length === 0 ? 'mr-btn-on' : ''}\">None</button>\n <button type=\"button\" onclick={() => applyGroup(['department'])}\n class=\"mr-btn {groupBy.join() === 'department' ? 'mr-btn-on' : ''}\">Department</button>\n <button type=\"button\" onclick={() => applyGroup(['country'])}\n class=\"mr-btn {groupBy.join() === 'country' ? 'mr-btn-on' : ''}\">Country</button>\n <button type=\"button\" onclick={() => applyGroup(['department','country'])}\n class=\"mr-btn {groupBy.join() === 'department,country' ? 'mr-btn-on' : ''}\">Department → Country</button>\n <button type=\"button\" onclick={() => applyGroup(['status'])}\n class=\"mr-btn {groupBy.join() === 'status' ? 'mr-btn-on' : ''}\">Status</button>\n\n <span class=\"mx-3 mr-note\">|</span>\n <label class=\"inline-flex items-center gap-2\">\n <input type=\"checkbox\" bind:checked={editing} />\n <span>Inline editing</span>\n </label>\n\n <span class=\"ml-auto text-xs mr-note\">\n Sort / filter via column headers · scroll the grid - only ~50 rows are mounted in the DOM at any moment.\n Row height is tightened to {ROW_H}px so all 1,000,000 rows stay within the browser's element-height limit.\n </span>\n </div>\n\n {#if busy}\n <div class=\"mr-progress shrink-0\">\n <div class=\"mr-progress-bar\"><div class=\"mr-progress-fill\" style={`width: ${pct}%`}></div></div>\n <div class=\"mr-progress-text tabular-nums\">Generating {fmt.format(generated)} / {fmt.format(TARGET)} · {pct}%</div>\n </div>\n {/if}\n\n {#if rows.length}\n <div class=\"flex-1 min-h-0\">\n <SvGrid\n columnResize\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showGroupingControls={true}\n enableInlineEditing={editing}\n enableCellSelection={true}\n showRowNumbers={true}\n virtualization={true}\n columnVirtualization={false}\n rowHeight={ROW_H}\n overscan={20}\n rowNumberWidth={92}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => { api = next; next.setGroupBy(groupBy) }}\n />\n </div>\n {/if}\n</section>\n\n<style>\n /* KPI strip */\n .mr-kpi-strip {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 10px;\n }\n .mr-kpi {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n border-radius: 10px;\n padding: 10px 14px;\n display: flex; flex-direction: column; gap: 2px;\n }\n .mr-kpi-label {\n font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;\n color: var(--sg-muted, #64748b);\n }\n .mr-kpi-value {\n font-size: 22px; font-weight: 700; line-height: 1.1;\n color: var(--sg-fg, #0f172a);\n }\n .mr-kpi-foot { font-size: 11px; color: var(--sg-muted, #94a3b8); }\n .mr-note { color: var(--sg-muted, #64748b); }\n .mr-up { color: #16a34a; }\n .mr-down { color: var(--sg-muted, #64748b); }\n\n /* Toolbar buttons */\n .mr-btn {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n border-radius: 6px;\n padding: 4px 12px;\n font-size: 13px;\n cursor: pointer;\n }\n .mr-btn:hover { background: var(--sg-row-hover-bg, rgba(148,163,184,0.10)); }\n .mr-btn-on {\n background: var(--sg-accent, #2563eb);\n /* Pair the accent bg with its contrast token, not a hardcoded white - themes\n like shadcn use a light accent in dark mode, where #fff would be invisible. */\n color: var(--sg-on-accent, #fff);\n border-color: transparent;\n font-weight: 600;\n }\n\n /* Progress bar */\n .mr-progress {\n display: flex; align-items: center; gap: 12px;\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n border-radius: 8px;\n padding: 10px 14px;\n }\n .mr-progress-bar {\n flex: 1;\n height: 8px;\n background: var(--sg-header-bg, #f1f5f9);\n border-radius: 999px;\n overflow: hidden;\n }\n .mr-progress-fill {\n height: 100%;\n background: var(--sg-accent, #6366f1);\n transition: width 100ms linear;\n }\n .mr-progress-text {\n font-size: 12px;\n color: var(--sg-muted, #64748b);\n white-space: nowrap;\n }\n</style>\n"
3272
+ "source": "<script lang=\"ts\">\n /**\n * 78. One million rows\n * --------------------\n * Row + column virtualization carrying a literal 1,000,000-row dataset\n * with sort, filter, group, scroll, and inline edit all enabled.\n *\n * Generation is chunked through requestAnimationFrame so the UI keeps\n * painting while the heap fills. The row shape is intentionally lean\n * (9 fields) - at 1M rows that's the difference between ~250 MB and\n * an unresponsive tab.\n */\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n type GridColumns,\n type SvGridApi,\n } from '@svgrid/grid'\n\n type Status = 'active' | 'pending' | 'inactive'\n\n type MillionRow = {\n id: string\n firstName: string\n lastName: string\n department: string\n country: string\n status: Status\n age: number\n salary: number\n performance: number\n }\n\n // --- Compact pools (kept short on purpose: smaller string-intern table\n // means 1M rows share a tiny set of pointers). ------------------------\n const FIRST = ['Ada','Linus','Grace','Alan','Margaret','Donald','Barbara','John',\n 'Edsger','Niklaus','Anders','Brendan','Yukihiro','Rasmus','Bjarne','Tim',\n 'Guido','Larry','James','Brian','Dennis','Ken','Mary','Susan','Karen','Lisa']\n const LAST = ['Lovelace','Torvalds','Hopper','Turing','Hamilton','Knuth','Liskov',\n 'McCarthy','Dijkstra','Wirth','Hejlsberg','Eich','Matsumoto','Lerdorf',\n 'Stroustrup','van Rossum','Wall','Gosling','Kernighan','Ritchie','Thompson']\n const DEPARTMENTS = ['Engineering','Design','Product','Sales','Support','Operations']\n const COUNTRIES = ['US','UK','DE','FR','JP','BR','IN','AU','CA','NL']\n const STATUSES: readonly Status[] = ['active','pending','inactive'] as const\n\n // --- Deterministic PRNG (Mulberry32) ------------------------------------\n function rng(seed: number) {\n let t = seed >>> 0\n return () => {\n t = (t + 0x6d2b79f5) >>> 0\n let x = t\n x = Math.imul(x ^ (x >>> 15), x | 1)\n x ^= x + Math.imul(x ^ (x >>> 7), x | 61)\n return ((x ^ (x >>> 14)) >>> 0) / 4_294_967_296\n }\n }\n\n const TARGET = 1_000_000\n const CHUNK = 50_000\n /**\n * Row height (px). 1M × 32 = 32M pixels, which exceeds Firefox's\n * element-height cap (~17.9M) and lands at Chrome's edge (~33.5M).\n * 18px keeps the total scroll height at 18M so EVERY row is reachable\n * in every browser. The grid still reads cleanly at this density.\n */\n const ROW_H = 18\n\n let rows = $state.raw<MillionRow[]>([])\n let generated = $state(0)\n let busy = $state(true)\n let buildMs = $state(0)\n\n function buildChunk(rand: () => number, into: MillionRow[], from: number, to: number) {\n const firstLen = FIRST.length\n const lastLen = LAST.length\n const deptLen = DEPARTMENTS.length\n const countryLen = COUNTRIES.length\n const statusLen = STATUSES.length\n for (let i = from; i < to; i++) {\n const dept = DEPARTMENTS[Math.floor(rand() * deptLen)]!\n const country = COUNTRIES[Math.floor(rand() * countryLen)]!\n const status = STATUSES[Math.floor(rand() * statusLen)]!\n into[i] = {\n id: 'r_' + i.toString(36),\n firstName: FIRST[Math.floor(rand() * firstLen)]!,\n lastName: LAST [Math.floor(rand() * lastLen)]!,\n department: dept,\n country,\n status,\n age: 21 + Math.floor(rand() * 45),\n salary: 35_000 + Math.floor(rand() * 165_000),\n performance: Math.floor(rand() * 100),\n }\n }\n }\n\n async function buildAll() {\n busy = true\n generated = 0\n const t0 = performance.now()\n const rand = rng(1337)\n const buf: MillionRow[] = new Array(TARGET)\n let cursor = 0\n while (cursor < TARGET) {\n const next = Math.min(cursor + CHUNK, TARGET)\n buildChunk(rand, buf, cursor, next)\n cursor = next\n generated = cursor\n // Yield to the browser so the progress bar paints and the page stays\n // responsive. Without this the JS thread runs solid for the full build.\n await new Promise<void>((r) => requestAnimationFrame(() => r()))\n }\n rows = buf\n buildMs = Math.round(performance.now() - t0)\n busy = false\n }\n\n $effect(() => {\n if (rows.length === 0 && busy) {\n void buildAll()\n }\n })\n\n // --- Grid configuration -------------------------------------------------\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n columnGroupingFeature,\n rowExpandingFeature,\n })\n\n const columns: GridColumns<MillionRow> = [\n { field: 'id', header: 'ID', editorType: 'text', width: 110, editable: false },\n { field: 'firstName', header: 'First name', editorType: 'text', width: 130 },\n { field: 'lastName', header: 'Last name', editorType: 'text', width: 140 },\n { field: 'department', header: 'Department', editorType: 'list',\n editorOptions: DEPARTMENTS as unknown as ReadonlyArray<string>, width: 140 },\n { field: 'country', header: 'Country', editorType: 'list',\n editorOptions: COUNTRIES as unknown as ReadonlyArray<string>, width: 100 },\n { field: 'status', header: 'Status', editorType: 'list',\n editorOptions: STATUSES as unknown as ReadonlyArray<string>, width: 110 },\n { field: 'age', header: 'Age', editorType: 'number', width: 90 },\n { field: 'salary', header: 'Salary', editorType: 'number', width: 130,\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } },\n { field: 'performance',header: 'Performance',editorType: 'number', width: 130 },\n ]\n\n let api = $state<SvGridApi<typeof features, MillionRow> | null>(null)\n let groupBy = $state<string[]>([])\n let editing = $state(true)\n\n function applyGroup(by: string[]) {\n groupBy = by\n api?.setGroupBy(by)\n }\n\n const pct = $derived(Math.min(100, Math.round((generated / TARGET) * 100)))\n const fmt = new Intl.NumberFormat('en-US')\n</script>\n\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\n <!-- KPI strip -->\n <div class=\"mr-kpi-strip shrink-0\">\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Rows</div>\n <div class=\"mr-kpi-value tabular-nums\">{fmt.format(rows.length || generated)}</div>\n <div class=\"mr-kpi-foot\">target {fmt.format(TARGET)}</div>\n </div>\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Columns</div>\n <div class=\"mr-kpi-value tabular-nums\">{columns.length}</div>\n <div class=\"mr-kpi-foot\">column virtualization on</div>\n </div>\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Build time</div>\n <div class=\"mr-kpi-value tabular-nums\">{busy ? '…' : buildMs + ' ms'}</div>\n <div class=\"mr-kpi-foot\">chunked, 50k / frame</div>\n </div>\n <div class=\"mr-kpi\">\n <div class=\"mr-kpi-label\">Editing</div>\n <div class={`mr-kpi-value ${editing ? 'mr-up' : 'mr-down'}`}>{editing ? 'ON' : 'off'}</div>\n <div class=\"mr-kpi-foot\">double-click any cell</div>\n </div>\n </div>\n\n <!-- Toolbar -->\n <div class=\"flex flex-wrap items-center gap-2 text-sm shrink-0\">\n <span class=\"font-medium\">Group by:</span>\n <button type=\"button\" onclick={() => applyGroup([])}\n class=\"mr-btn {groupBy.length === 0 ? 'mr-btn-on' : ''}\">None</button>\n <button type=\"button\" onclick={() => applyGroup(['department'])}\n class=\"mr-btn {groupBy.join() === 'department' ? 'mr-btn-on' : ''}\">Department</button>\n <button type=\"button\" onclick={() => applyGroup(['country'])}\n class=\"mr-btn {groupBy.join() === 'country' ? 'mr-btn-on' : ''}\">Country</button>\n <button type=\"button\" onclick={() => applyGroup(['department','country'])}\n class=\"mr-btn {groupBy.join() === 'department,country' ? 'mr-btn-on' : ''}\">Department → Country</button>\n <button type=\"button\" onclick={() => applyGroup(['status'])}\n class=\"mr-btn {groupBy.join() === 'status' ? 'mr-btn-on' : ''}\">Status</button>\n\n <span class=\"mx-3 mr-note\">|</span>\n <label class=\"inline-flex items-center gap-2\">\n <input type=\"checkbox\" bind:checked={editing} />\n <span>Inline editing</span>\n </label>\n\n <span class=\"ml-auto text-xs mr-note\">\n Sort / filter via column headers · scroll the grid - only ~50 rows are mounted in the DOM at any moment.\n Row height is tightened to {ROW_H}px so all 1,000,000 rows stay within the browser's element-height limit.\n </span>\n </div>\n\n {#if busy}\n <div class=\"mr-progress shrink-0\">\n <div class=\"mr-progress-bar\"><div class=\"mr-progress-fill\" style={`width: ${pct}%`}></div></div>\n <div class=\"mr-progress-text tabular-nums\">Generating {fmt.format(generated)} / {fmt.format(TARGET)} · {pct}%</div>\n </div>\n {/if}\n\n {#if rows.length}\n <div class=\"mr-grid flex-1 min-h-0\">\n <SvGrid\n columnResize\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n selectionMode=\"cell\"\n showGroupingControls={true}\n enableInlineEditing={editing}\n enableCellSelection={true}\n showRowNumbers={true}\n virtualization={true}\n columnVirtualization={false}\n rowHeight={ROW_H}\n overscan={20}\n rowNumberWidth={92}\n containerHeight=\"100%\"\n fitColumns={true}\n onApiReady={(next) => { api = next; next.setGroupBy(groupBy) }}\n />\n </div>\n {/if}\n</section>\n\n<style>\n /* Text that fits the 18 px rows. The cells inherit the page's 16 px type\n on a 24 px line, which needs a 25 px row; a row is exactly its\n rowHeight, so that line would be cramped into 18 px. */\n .mr-grid { font-size: 13px; line-height: 17px; }\n\n /* KPI strip */\n .mr-kpi-strip {\n display: grid;\n grid-template-columns: repeat(4, minmax(0, 1fr));\n gap: 10px;\n }\n .mr-kpi {\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n border-radius: 10px;\n padding: 10px 14px;\n display: flex; flex-direction: column; gap: 2px;\n }\n .mr-kpi-label {\n font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase;\n color: var(--sg-muted, #64748b);\n }\n .mr-kpi-value {\n font-size: 22px; font-weight: 700; line-height: 1.1;\n color: var(--sg-fg, #0f172a);\n }\n .mr-kpi-foot { font-size: 11px; color: var(--sg-muted, #94a3b8); }\n .mr-note { color: var(--sg-muted, #64748b); }\n .mr-up { color: #16a34a; }\n .mr-down { color: var(--sg-muted, #64748b); }\n\n /* Toolbar buttons */\n .mr-btn {\n border: 1px solid var(--sg-border, #cbd5e1);\n background: var(--sg-bg, #fff);\n color: var(--sg-fg, #0f172a);\n border-radius: 6px;\n padding: 4px 12px;\n font-size: 13px;\n cursor: pointer;\n }\n .mr-btn:hover { background: var(--sg-row-hover-bg, rgba(148,163,184,0.10)); }\n .mr-btn-on {\n background: var(--sg-accent, #2563eb);\n /* Pair the accent bg with its contrast token, not a hardcoded white - themes\n like shadcn use a light accent in dark mode, where #fff would be invisible. */\n color: var(--sg-on-accent, #fff);\n border-color: transparent;\n font-weight: 600;\n }\n\n /* Progress bar */\n .mr-progress {\n display: flex; align-items: center; gap: 12px;\n border: 1px solid var(--sg-border, #e2e8f0);\n background: var(--sg-bg, #fff);\n border-radius: 8px;\n padding: 10px 14px;\n }\n .mr-progress-bar {\n flex: 1;\n height: 8px;\n background: var(--sg-header-bg, #f1f5f9);\n border-radius: 999px;\n overflow: hidden;\n }\n .mr-progress-fill {\n height: 100%;\n background: var(--sg-accent, #6366f1);\n transition: width 100ms linear;\n }\n .mr-progress-text {\n font-size: 12px;\n color: var(--sg-muted, #64748b);\n white-space: nowrap;\n }\n</style>\n"
3265
3273
  },
3266
3274
  {
3267
3275
  "id": "79-loading-from-rest",
@@ -3670,7 +3678,7 @@ export const docs = [
3670
3678
  "path": "docs/enterprise/studio/code-generation.md",
3671
3679
  "title": "Code generation",
3672
3680
  "section": "Studio",
3673
- "markdown": "# Code generation\n\nStudio generates plain SvelteKit code you own - there is no runtime and no\nlock-in. The same `scaffold()` core powers the [CLI](./cli.md), the\n[AI generator](./ai-generation.md), and the [visual designer](./app-designer.md), so\nall three emit identical files.\n\n![The generated files: schema module, +server.ts API route, and +page.svelte screen, with svgrid:managed markers.](/docs-media/studio-generated-code.png)\n\n## What gets generated\n\nFrom one `EntitySchema`, three files:\n\n| File | Contents |\n| --- | --- |\n| `src/lib/<name>.schema.ts` | The `EntitySchema` literal + a typed row type. |\n| `src/routes/api/<name>/+server.ts` | The API route - `createKitHandlers` over a data source. |\n| `src/routes/<name>/+page.svelte` | The screen - grid + edit panel, wired to the route. |\n\nThe generated page is a full data screen: server-side sort, filter, and global\nsearch; a native pagination footer; a validated create / edit modal;\nmulti-select optimistic delete; and loading / error / empty states. Any\n`relation` (foreign-key) field also gets a searchable **lookup** wired to the\nrelated entity's API route ([Relations](./relations.md)).\n\n## The three files, up close\n\n**1. The schema** is the single source of truth - a plain literal you can edit by\nhand or regenerate:\n\n```ts\n// src/lib/customers.schema.ts\nimport type { EntitySchema } from '@svgrid/enterprise'\n\nexport type CustomersRow = {\n id: string; name: string; email: string; mrr: number; active: boolean\n}\n\nexport const customersSchema: EntitySchema<CustomersRow> = {\n name: 'customers',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true },\n { field: 'email', type: 'text', format: 'email', required: true },\n { field: 'mrr', type: 'number', min: 0 },\n { field: 'active', type: 'boolean' },\n ],\n}\n```\n\n**2. The API route** is a single `POST` endpoint. `createKitHandlers` speaks one\nJSON wire protocol for read + create + update + delete, so the client needs just\nthe one handler:\n\n```ts\n// src/routes/api/customers/+server.ts\nimport { createInMemoryDataSource, createKitHandlers } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\n\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\nSwap `createInMemoryDataSource` for a SQL, Supabase, or REST source and the page\ndoes not change - that is the [ServerDataSource](./data-binding.md) contract at\nwork.\n\n**3. The screen** wires the grid and the edit panel to that route through the\ncontroller - server-side sort / filter / page, the create-edit modal, and\noptimistic delete. It is a normal `+page.svelte` you own; see the\n[Studio live SQL demo](https://svgrid.com/demos/193-studio-live-sql/) for the\nwhole thing running.\n\n## Managed regions & safe regeneration\n\nEvery generated file wraps its body in markers:\n\n```ts\n// svgrid:managed:start\n// Regenerated by SvGrid Studio. Edits inside these markers are overwritten.\n...generated code...\n// svgrid:managed:end\n```\n\nRe-running generation replaces **only** the managed region and preserves\neverything outside it. So you can:\n\n- Add imports, helpers, and layout **outside** the markers - they survive\n regeneration.\n- Re-run `add` after a schema or database change to pull in new columns without\n losing your customizations.\n\nThis idempotent regeneration is what makes the generator safe to keep using,\nrather than a one-shot scaffold you fork away from.\n\n## Choosing the data source\n\nThe generated `+server.ts` backend depends on how you scaffold:\n\n| Scaffold | Backend |\n| --- | --- |\n| `--db <dialect>` | Connected to that driver via `process.env.DATABASE_URL`. |\n| `--sql` | `createSqlDataSource` with an `execute()` stub to fill in. |\n| `--from` (no `--db`) | In-memory, so the screen runs immediately. |\n\nSee [Databases](./databases.md) and [Data binding](./data-binding.md).\n\n## Render mode: SPA or SSR per screen\n\nAn **`ssr`** screen emits idiomatic server-rendered SvelteKit: a\n`+page.server.ts` with a `load` function and form `actions`, URL-driven sort /\nfilter / page state, and progressive enhancement. A **`spa`** screen emits a\nclient page where the browser talks to the API route through the data-source\ncontroller.\n\n**New apps built on a database or a REST API get `ssr` for free.** When you\ngenerate an app - `svgrid-studio init`, the designer's **New app** wizard, or\n`crudAppFromSchemas` - every screen that qualifies starts in `ssr`. You can still\nswitch any screen either way in the [app designer](./app-designer.md).\n\nIn-memory and PGlite apps stay `spa`, on purpose. Those sources are module\nsingletons, so a server-rendered screen would read and write the server's copy of\nthe rows while the app's remaining client screens read the browser's: add a row\non one and the other never sees it. SQL and REST have no such split, because\nevery path goes to the same database or the same remote API.\n\n### How the app is wired\n\nOnce an app has at least one server-rendered screen, the root `src/routes/+layout.ts`\nleaves SvelteKit's own default in place - server rendering on - and each\nclient-only screen opts out in its own `+page.ts`:\n\n```ts\n// src/routes/<screen>/+page.ts\nexport const ssr = false\n```\n\nSo the nav shell, the home page, and the sign-in pages all render on the server,\nand only the screens that fetch in the browser skip it. An app with nothing to\nserver-render keeps the single `export const ssr = false` in the root layout, as\nbefore.\n\nNot every screen shape can emit as SSR. The rules:\n\n- The screen must be entity-bound, without a [code-behind](./code-behind.md)\n companion.\n- Its data source must be `memory` (runs in-process), `sql` (reuses the connected\n `/api` route via `event.fetch`), or `rest` **on an absolute URL** (the server\n calls the remote API directly; a relative URL has no origin to resolve against\n there, so it stays SPA). `supabase` and `pglite` screens stay SPA - PGlite only\n exists in the browser, and a Supabase read carries the signed-in user's token,\n which a server-side call with the anon key would silently drop.\n- **A grid, optionally with a facet panel**, emits as load + form actions (no\n tree data, no scheduler view). The facet panel becomes a plain `GET` form\n whose controls are named for the URL params the `load` reads, so filtering\n works with JavaScript off and every filtered view has a shareable URL.\n- **Read-only block screens** - any mix of chart, pivot, dashboard, KPI, gauge,\n tree, detail, and master-detail - emit as a load-only page.\n- Anything else (boards, calendars, UI component blocks, containers,\n grids with extras) stays SPA.\n\nThe designer only offers the toggle when the screen qualifies; a screen set to\n`ssr` that stops qualifying falls back to the SPA emit.\n\n## Verification\n\nThe AI path compiles the generated page (via the Svelte compiler) before handing\nit back, and the recommended final step everywhere is your project's own check:\n\n```bash\nnpx svelte-check\n```\n\n## See also\n\n- [The Studio CLI](./cli.md) · [AI generation](./ai-generation.md) · [Visual app designer](./app-designer.md)\n"
3681
+ "markdown": "# Code generation\n\nStudio generates plain SvelteKit code you own - there is no runtime and no\nlock-in. The same `scaffold()` core powers the [CLI](./cli.md), the\n[AI generator](./ai-generation.md), and the [visual designer](./app-designer.md), so\nall three emit identical files.\n\n![The generated files: schema module, +server.ts API route, and +page.svelte screen, with svgrid:managed markers.](/docs-media/studio-generated-code.png)\n\n## What gets generated\n\nFrom one `EntitySchema`, three files:\n\n| File | Contents |\n| --- | --- |\n| `src/lib/<name>.schema.ts` | The `EntitySchema` literal + a typed row type. |\n| `src/routes/api/<name>/+server.ts` | The API route - `createKitHandlers` over a data source. |\n| `src/routes/<name>/+page.svelte` | The screen - grid + edit panel, wired to the route. |\n\nThe generated page is a full data screen: server-side sort, filter, and global\nsearch; a native pagination footer; a validated create / edit modal;\nmulti-select optimistic delete; and loading / error / empty states. Any\n`relation` (foreign-key) field also gets a searchable **lookup** wired to the\nrelated entity's API route ([Relations](./relations.md)).\n\n## The three files, up close\n\n**1. The schema** is the single source of truth - a plain literal you can edit by\nhand or regenerate:\n\n```ts\n// src/lib/customers.schema.ts\nimport type { EntitySchema } from '@svgrid/enterprise'\n\nexport type CustomersRow = {\n id: string; name: string; email: string; mrr: number; active: boolean\n}\n\nexport const customersSchema: EntitySchema<CustomersRow> = {\n name: 'customers',\n idField: 'id',\n fields: [\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\n { field: 'name', type: 'text', required: true },\n { field: 'email', type: 'text', format: 'email', required: true },\n { field: 'mrr', type: 'number', min: 0 },\n { field: 'active', type: 'boolean' },\n ],\n}\n```\n\n**2. The API route** is a single `POST` endpoint. `createKitHandlers` speaks one\nJSON wire protocol for read + create + update + delete, so the client needs just\nthe one handler:\n\n```ts\n// src/routes/api/customers/+server.ts\nimport { createInMemoryDataSource, createKitHandlers } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\n\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\nSwap `createInMemoryDataSource` for a SQL, Supabase, or REST source and the page\ndoes not change - that is the [ServerDataSource](./data-binding.md) contract at\nwork.\n\n**3. The screen** wires the grid and the edit panel to that route through the\ncontroller - server-side sort / filter / page, the create-edit modal, and\noptimistic delete. It is a normal `+page.svelte` you own; see the\n[Studio live SQL demo](https://svgrid.com/demos/193-studio-live-sql/) for the\nwhole thing running.\n\n## Managed regions & safe regeneration\n\nEvery generated file wraps its body in markers:\n\n```ts\n// svgrid:managed:start\n// Regenerated by SvGrid Studio. Edits inside these markers are overwritten.\n...generated code...\n// svgrid:managed:end\n```\n\nRe-running generation replaces **only** the managed region and preserves\neverything outside it. So you can:\n\n- Add imports, helpers, and layout **outside** the markers - they survive\n regeneration.\n- Re-run `add` after a schema or database change to pull in new columns without\n losing your customizations.\n\nThis idempotent regeneration is what makes the generator safe to keep using,\nrather than a one-shot scaffold you fork away from.\n\n## Choosing the data source\n\nThe generated `+server.ts` backend depends on how you scaffold:\n\n| Scaffold | Backend |\n| --- | --- |\n| `--db <dialect>` | Connected to that driver via `process.env.DATABASE_URL`. |\n| `--sql` | `createSqlDataSource` with an `execute()` stub to fill in. |\n| `--from` (no `--db`) | In-memory, so the screen runs immediately. |\n\nSee [Databases](./databases.md) and [Data binding](./data-binding.md).\n\n## Render mode: SPA or SSR per screen\n\nAn **`ssr`** screen emits idiomatic server-rendered SvelteKit: a\n`+page.server.ts` with a `load` function and form `actions`, URL-driven sort /\nfilter / page state, and progressive enhancement. A **`spa`** screen emits a\nclient page where the browser talks to the API route through the data-source\ncontroller.\n\n**New apps get `ssr` wherever a screen qualifies.** When you generate an app -\n`svgrid-studio init`, the designer's **New app** wizard, or `crudAppFromSchemas` -\nevery qualifying screen on a SQL, REST, or in-memory entity starts in `ssr`. You\ncan still switch any screen either way in the [app designer](./app-designer.md),\nand a screen you set to `spa` stays `spa`.\n\nIn-memory data is held once, on the server. The seeded rows live in\n`src/lib/server/store.ts`, and every screen reaches them through the entity's\n`/api/<entity>` route, the same way a SQL entity does. So a server-rendered screen\nand a client screen read and write the same rows, and the route's validation,\naccess rules, and triggers apply to both. See\n[In-memory](./in-memory.md#in-a-studio-app) for what that means in a deployed app.\n\nPGlite apps stay `spa`: PGlite keeps its rows in the browser's IndexedDB, which\nthe server cannot read.\n\n### How the app is wired\n\nOnce an app has at least one server-rendered screen, the root `src/routes/+layout.ts`\nleaves SvelteKit's own default in place - server rendering on - and each\nclient-only screen opts out in its own `+page.ts`:\n\n```ts\n// src/routes/<screen>/+page.ts\nexport const ssr = false\n```\n\nSo the nav shell, the home page, and the sign-in pages all render on the server,\nand only the screens that fetch in the browser skip it. An app with nothing to\nserver-render keeps the single `export const ssr = false` in the root layout, as\nbefore.\n\nNot every screen shape can emit as SSR. The rules:\n\n- The screen must be entity-bound, without a [code-behind](./code-behind.md)\n companion.\n- Its data source must be `memory` or `sql` (both reuse the entity's `/api` route\n via `event.fetch`), or `rest` **on an absolute URL** (the server\n calls the remote API directly; a relative URL has no origin to resolve against\n there, so it stays SPA). `supabase` and `pglite` screens stay SPA - PGlite only\n exists in the browser, and a Supabase read carries the signed-in user's token,\n which a server-side call with the anon key would silently drop.\n- **A grid, optionally with a facet panel**, emits as load + form actions (no\n tree data, no scheduler view). The facet panel becomes a plain `GET` form\n whose controls are named for the URL params the `load` reads, so filtering\n works with JavaScript off and every filtered view has a shareable URL.\n- **Read-only block screens** - any mix of chart, pivot, dashboard, KPI, gauge,\n tree, detail, and master-detail - emit as a load-only page.\n- Anything else (boards, calendars, UI component blocks, containers,\n grids with extras) stays SPA.\n\nThe designer only offers the toggle when the screen qualifies; a screen set to\n`ssr` that stops qualifying falls back to the SPA emit.\n\n## Verification\n\nThe AI path compiles the generated page (via the Svelte compiler) before handing\nit back, and the recommended final step everywhere is your project's own check:\n\n```bash\nnpx svelte-check\n```\n\n## See also\n\n- [The Studio CLI](./cli.md) · [AI generation](./ai-generation.md) · [Visual app designer](./app-designer.md)\n"
3674
3682
  },
3675
3683
  {
3676
3684
  "slug": "enterprise/studio/concepts",
@@ -3754,7 +3762,7 @@ export const docs = [
3754
3762
  "path": "docs/enterprise/studio/in-memory.md",
3755
3763
  "title": "In-memory",
3756
3764
  "section": "Studio",
3757
- "markdown": "# In-memory\n\nThe in-memory source runs a full CRUD data screen against a plain array - no\ndatabase, no API. It applies the grid's filter, global search, sort, and paging\nin memory, and supports create / update / delete. Ideal for prototyping a schema,\ntests, demos, static reference data, or seeding a screen before the backend\nexists.\n\n![A full CRUD screen sends filter, search, sort, page, and create/update/delete through the in-memory source, which applies them to a plain array in memory - no database, no API.](/docs-media/studio-in-memory.svg)\n\nIt is the default backend the CLI emits when you scaffold from a\n[Drizzle schema](./drizzle.md) without `--db`, so the screen runs the moment you\n`npm run dev`.\n\n## Usage\n\n```ts\nimport { createServerDataSource } from '@svgrid/grid'\nimport { createInMemoryDataSource } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\nconst seed: CustomersRow[] = [\n { id: '1', name: 'Ada Lovelace', email: 'ada@analytic.io', mrr: 1200, active: true },\n { id: '2', name: 'Alan Turing', email: 'alan@bletchley.uk', mrr: 240, active: true },\n // ...\n]\n\nconst source = createInMemoryDataSource(seed, customersSchema)\n\nconst controller = createServerDataSource(source, {\n pageSize: 25,\n optimistic: true,\n getRowId: (r) => r.id,\n onChange: (s) => (view = s),\n})\ncontroller.refresh()\n```\n\n`createInMemoryDataSource(rows, schema)` returns a full `ServerDataSource`\n(read + create + update + delete). Everything the grid asks for - filter\noperators, facet selections, global search, multi-column sort, paging - is\napplied to the array, exactly like a real backend, so you can build the whole\nscreen and swap in a database later without changing the UI.\n\n## Server route\n\nTo expose it behind a SvelteKit API route (as the generated code does):\n\n```ts\n// src/routes/api/customers/+server.ts\nimport { createInMemoryDataSource, createKitHandlers } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\n## Reseeding and fixtures\n\n`createInMemoryDataSource(rows, schema)` copies the rows in - the array you pass\nis a seed, not a live binding. Load it from a JSON fixture to keep sample data out\nof your components:\n\n```ts\nimport seed from '$lib/fixtures/customers.json'\nconst source = createInMemoryDataSource(seed, customersSchema)\n```\n\nBecause the data lives inside the source, a fresh `createInMemoryDataSource(...)`\nis a clean slate - handy between tests, or behind a \"reset demo data\" button.\n\n## In tests\n\nThe source is a plain object implementing `ServerDataSource`, so you can exercise\nthe whole screen's data behaviour directly - no DOM, no database:\n\n```ts\nimport { describe, it, expect } from 'vitest'\nimport { createInMemoryDataSource } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\ndescribe('customers screen data', () => {\n it('filters, sorts, creates, and deletes', async () => {\n const source = createInMemoryDataSource<CustomersRow>([\n { id: '1', name: 'Ada Lovelace', email: 'ada@analytic.io', mrr: 1200, active: true },\n { id: '2', name: 'Alan Turing', email: 'alan@bletchley.uk', mrr: 240, active: false },\n ], customersSchema)\n\n // read a page exactly the way the grid asks (see Data binding)\n const page = await source.getRows({\n startRow: 0, endRow: 25, pageIndex: 0, pageSize: 25,\n sortModel: [{ id: 'mrr', desc: true }],\n filterModel: { columns: { active: { operator: 'equals', value: 'true' } } },\n })\n expect(page.rowCount).toBe(1)\n expect(page.rows[0]!.name).toBe('Ada Lovelace')\n\n const created = await source.createRow!({ name: 'Grace Hopper', email: 'grace@navy.mil', mrr: 900, active: true })\n await source.deleteRow!(created.id)\n })\n})\n```\n\nIt is the same `ServerRequest` the grid sends at runtime, so a test that passes\nhere is a screen that behaves.\n\n## When to move on\n\nThe in-memory source keeps its data in the server process, so it resets on\nrestart and is not shared across instances. When you are ready for persistence,\nswitch to a [database](./databases.md) or a [custom source](./rest-api.md) - the\nschema, grid, and form stay exactly the same.\n\n## See also\n\n- [Data binding](./data-binding.md) · [Databases](./databases.md) · [Drizzle schema](./drizzle.md)\n"
3765
+ "markdown": "# In-memory\n\nThe in-memory source runs a full CRUD data screen against a plain array - no\ndatabase, no API. It applies the grid's filter, global search, sort, and paging\nin memory, and supports create / update / delete. Ideal for prototyping a schema,\ntests, demos, static reference data, or seeding a screen before the backend\nexists.\n\n![A full CRUD screen sends filter, search, sort, page, and create/update/delete through the in-memory source, which applies them to a plain array in memory - no database, no API.](/docs-media/studio-in-memory.svg)\n\nIt is the default backend the CLI emits when you scaffold from a\n[Drizzle schema](./drizzle.md) without `--db`, so the screen runs the moment you\n`npm run dev`.\n\n## Usage\n\n```ts\nimport { createServerDataSource } from '@svgrid/grid'\nimport { createInMemoryDataSource } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\nconst seed: CustomersRow[] = [\n { id: '1', name: 'Ada Lovelace', email: 'ada@analytic.io', mrr: 1200, active: true },\n { id: '2', name: 'Alan Turing', email: 'alan@bletchley.uk', mrr: 240, active: true },\n // ...\n]\n\nconst source = createInMemoryDataSource(seed, customersSchema)\n\nconst controller = createServerDataSource(source, {\n pageSize: 25,\n optimistic: true,\n getRowId: (r) => r.id,\n onChange: (s) => (view = s),\n})\ncontroller.refresh()\n```\n\n`createInMemoryDataSource(rows, schema)` returns a full `ServerDataSource`\n(read + create + update + delete). Everything the grid asks for - filter\noperators, facet selections, global search, multi-column sort, paging - is\napplied to the array, exactly like a real backend, so you can build the whole\nscreen and swap in a database later without changing the UI.\n\n## Server route\n\nTo expose it behind a SvelteKit API route (as the generated code does):\n\n```ts\n// src/routes/api/customers/+server.ts\nimport { createInMemoryDataSource, createKitHandlers } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\n```\n\n## Reseeding and fixtures\n\n`createInMemoryDataSource(rows, schema)` copies the rows in - the array you pass\nis a seed, not a live binding. Load it from a JSON fixture to keep sample data out\nof your components:\n\n```ts\nimport seed from '$lib/fixtures/customers.json'\nconst source = createInMemoryDataSource(seed, customersSchema)\n```\n\nBecause the data lives inside the source, a fresh `createInMemoryDataSource(...)`\nis a clean slate - handy between tests, or behind a \"reset demo data\" button.\n\n## In tests\n\nThe source is a plain object implementing `ServerDataSource`, so you can exercise\nthe whole screen's data behaviour directly - no DOM, no database:\n\n```ts\nimport { describe, it, expect } from 'vitest'\nimport { createInMemoryDataSource } from '@svgrid/enterprise'\nimport { customersSchema, type CustomersRow } from '$lib/customers.schema'\n\ndescribe('customers screen data', () => {\n it('filters, sorts, creates, and deletes', async () => {\n const source = createInMemoryDataSource<CustomersRow>([\n { id: '1', name: 'Ada Lovelace', email: 'ada@analytic.io', mrr: 1200, active: true },\n { id: '2', name: 'Alan Turing', email: 'alan@bletchley.uk', mrr: 240, active: false },\n ], customersSchema)\n\n // read a page exactly the way the grid asks (see Data binding)\n const page = await source.getRows({\n startRow: 0, endRow: 25, pageIndex: 0, pageSize: 25,\n sortModel: [{ id: 'mrr', desc: true }],\n filterModel: { columns: { active: { operator: 'equals', value: 'true' } } },\n })\n expect(page.rowCount).toBe(1)\n expect(page.rows[0]!.name).toBe('Ada Lovelace')\n\n const created = await source.createRow!({ name: 'Grace Hopper', email: 'grace@navy.mil', mrr: 900, active: true })\n await source.deleteRow!(created.id)\n })\n})\n```\n\nIt is the same `ServerRequest` the grid sends at runtime, so a test that passes\nhere is a screen that behaves.\n\n## In a Studio app\n\nA Studio-generated app holds its in-memory rows on the server. Three files carry\nit:\n\n- `src/lib/server/store.ts` - the seeded rows of every in-memory entity, one\n `createInMemoryDataSource` each. Only server code imports it, so the seed data\n stays out of the browser bundle.\n- `src/routes/api/<entity>/+server.ts` - `createKitHandlers` over that store,\n with the same validation, access rules, triggers, and audit hook a SQL route\n gets.\n- `src/lib/data.ts` - the browser side: a `createKitDataSource` client per\n entity, pointed at its route.\n\nServer-rendered screens read through the same route (with SvelteKit's\n`event.fetch`), so every screen sees the same rows whichever way it renders.\nThe server also assigns ids: a create that arrives with no id, or with one that\nis already taken, gets the next free one, so two open tabs cannot hand out the\nsame id.\n\nWhat that means once the app is deployed:\n\n- Everyone using the app shares one copy of the data. An edit one person makes\n is what the next person sees.\n- A restart, a redeploy, or a dev-server reload of `store.ts` puts the seed rows\n back.\n- On a serverless host each instance holds its own copy, so two requests can\n land on different data.\n\n## When to move on\n\nThe in-memory source keeps its data in the server process, so it resets on\nrestart and is not shared across instances. When you are ready for persistence,\nswitch to a [database](./databases.md) or a [custom source](./rest-api.md) - the\nschema, grid, and form stay exactly the same.\n\n## See also\n\n- [Data binding](./data-binding.md) · [Databases](./databases.md) · [Drizzle schema](./drizzle.md)\n"
3758
3766
  },
3759
3767
  {
3760
3768
  "slug": "enterprise/studio/launch",
@@ -3920,9 +3928,9 @@ export const docs = [
3920
3928
  {
3921
3929
  "slug": "getting-started-full",
3922
3930
  "path": "docs/getting-started-full.md",
3923
- "title": "Getting Started with SvGrid",
3931
+ "title": "Getting started: the complete guide on one page",
3924
3932
  "section": "Overview",
3925
- "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> `@svgrid/grid` is published under the **MIT License** - permissive\n> for commercial use, redistribution, and modification. The paid companion\n> `@svgrid/enterprise` (data export + print) ships under a separate commercial\n> license. See [LICENSE](../LICENSE) and\n> [packages/enterprise/LICENSE](../packages/enterprise/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\nEvery example below runs against this setup:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature, rowPaginationFeature, type ColumnDef } from '@svgrid/grid'\n\n type Person = { id: number; name: string; email: string; age: number; city: string }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowPaginationFeature })\n\n let rows = $state<Person[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', age: 36, city: 'London' },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', age: 45, city: 'New York' },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', age: 54, city: 'Portland' },\n ])\n const data = rows\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'name', header: 'Name' },\n { field: 'email', header: 'Email' },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'city', header: 'City' },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { firstName: string; age: number; status: string }\n\n const rows: Person[] = [\n { firstName: 'Ada', age: 36, status: 'active' },\n { firstName: 'Linus', age: 54, status: 'active' },\n { firstName: 'Grace', age: 85, status: 'inactive' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First name' },\n { field: 'age', header: 'Age' },\n { field: 'status', header: 'Status' },\n ]\n</script>\n\n<SvGrid data={rows} columns={columns} />\n```\n\nThat's a complete, working grid. 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 @svgrid/grid\n\n# npm\nnpm install @svgrid/grid\n\n# yarn\nyarn add @svgrid/grid\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 features you want, and the\nmatching `ColumnDef` type:\n\n```ts\nimport {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature, type GridColumns,\n} from '@svgrid/grid'\n```\n\nThe bundle is tree-shakeable - features you don't import don't ship. The\ndefault render component (`<SvGrid>`) brings its own scoped CSS, so\nthere's no separate stylesheet to import. Re-theming happens via the\n`--sg-*` custom-property surface; see\n[Tailwind integration](./help/tailwind.md) for the full list.\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 '@svgrid/grid'\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.** Today the wrapper uses the row's array index as its id.\nThat is fine for read-only data; if you mutate `rows`, prefer keeping\nthe same object references for rows that didn't change so selection\nand edit state line up. A `getRowId` prop on the wrapper is tracked in\n[Missing features](./help/missing-features.md) and supported by the\nheadless `createSvGrid` core today.\n\n**Immutability.** 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 '@svgrid/grid'\n\ntype Person = {\n id: string\n firstName: string\n lastName: string\n age: number\n joinedAt: string // ISO date\n salary: number\n active: boolean\n}\n\nconst columns: GridColumns<Person> = [\n // `field`: read a key straight off the row\n { field: 'firstName', header: 'First name' },\n\n // `fieldFn`: compute the value; needs an explicit `id`\n {\n id: 'fullName',\n header: 'Full name',\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\n },\n\n // Numeric with locale-aware formatting\n {\n field: 'age',\n header: 'Age',\n format: { type: 'number', options: { maximumFractionDigits: 0 } },\n },\n\n // Date with explicit pattern\n {\n field: 'joinedAt',\n header: 'Joined',\n format: { type: 'date', pattern: 'y-m-d' },\n },\n\n // Currency\n {\n field: 'salary',\n header: 'Salary',\n format: { type: 'currency', currency: 'USD' },\n },\n\n // Boolean rendered as a checkbox\n {\n field: 'active',\n header: 'Active',\n editorType: 'checkbox',\n },\n]\n```\n\n**Common properties.**\n\n| Property | Purpose |\n| --- | --- |\n| `field` | Reads `row[key]`. |\n| `fieldFn` | Computes the value from the row. |\n| `id` | Stable column id (required if you use `fieldFn`). |\n| `header` | String or render snippet for the header. |\n| `footer` | String or render snippet for the footer row. |\n| `cell` | Render snippet/component for the body cell. |\n| `format` | Locale-aware formatter (`number`, `currency`, `percent`, `date`). |\n| `formatter` | Function for one-off custom value formatting. |\n| `editorType` | Inline editor: `text` \\| `number` \\| `checkbox` \\| `date` \\| `datetime`. |\n| `width` | Initial column width in pixels (default `columnWidth` prop). |\n| `align` | Header + body alignment: `'left'` \\| `'right'` \\| `'center'`. Inferred from `editorType` when omitted. |\n| `columns` | Child column defs (for column groups). |\n\nSorting / filtering / grouping are toggled per-grid via the registered\nfeatures - there is no per-column `enableSorting` / `enableColumnFilter`\nflag yet; those entries are in [Missing features](./help/missing-features.md).\n\nSee [`packages/grid/src/core.ts`](../packages/grid/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 rowSelectionFeature, type GridColumns,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination={true}\n pageSize={25}\n/>\n```\n\n**Rule of thumb.** Only register the features you use. The wrapper wires\nthe matching row-model pipeline (core → filtered → sorted → grouped →\nexpanded) for you and exposes the user-facing toggles via props\n(`showPagination`, `filterMode`, `showRowSelection`, …). If you need the\nheadless pipeline directly - e.g. a custom renderer - drop down to\n`createSvGrid` from the same package; see [Why headless?](./why-headless.md).\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's render component (`<SvGrid>`) ships its own scoped styles. You\nre-theme it by declaring `--sg-*` custom properties at any level above\nthe grid - the included light, dark, and high-contrast palettes in the\n[`10-custom-cells-and-themes`](../examples/src/demos/10-custom-cells-and-themes.svelte)\ndemo are themselves just three sets of these tokens applied via\n`style=\"--sg-bg: …; --sg-fg: …; …\"`.\n\n**Customising tokens.** Override at any level - `:root`, a wrapper, or\ndirectly on `<SvGrid>`.\n\n```css\n:root {\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.** Row height is the one piece of the look that is a prop\nrather than a token - the virtualizer needs it as a number, and the\ngrid writes it as an inline style on each row. Pass `rowHeight`\n(default `30`): `28` for compact, `48` for comfortable. Changing it\ndoes not remount 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\nThe grid's scroll shell is sized by `containerHeight`, which defaults to\n520 px. A number is pixels; a string is passed through to CSS, so `'100%'`\nfills the parent and `'auto'` grows to the content.\n\n```svelte {runnable}\n<!-- Fixed: 600px tall. The typical choice. -->\n<SvGrid data={rows} columns={columns} containerHeight={600} />\n\n<!-- Flexible: fills a parent that sets the height itself. -->\n<div class=\"grid-shell\">\n <SvGrid data={rows} columns={columns} containerHeight=\"100%\" />\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. Turn row virtualization\noff as well - a shell with no fixed height gives the virtualizer nothing\nto measure against:\n\n```svelte {runnable}\n<SvGrid\n data={rows}\n columns={columns}\n containerHeight=\"auto\"\n virtualization={false}\n/>\n```\n\nDon't use this for large data: every row renders.\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 GridColumns } from '@svgrid/grid'\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: GridColumns<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 '@svgrid/grid'\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**Quick way - capability shortcuts.** Every capability is off by default;\nthe fastest way to opt in is a boolean shortcut prop, no feature constants\nrequired. `sortable` and `filterable` inject the matching feature for you;\n`editable`, `groupable`, and `pageable` alias `enableInlineEditing`,\n`showGroupingControls`, and `showPagination`:\n\n```svelte {runnable}\n<SvGrid data={rows} columns={columns} sortable filterable editable groupable pageable />\n```\n\nReach for the explicit `features` set + fine-grained props below when you\nneed more control (filter mode, page size, per-column opt-outs).\n\n### Uncontrolled (the default)\n\nThe wrapper owns sort, filter, pagination, selection, and expansion\nstate by default. Set the initial page size and which filter UI to\nshow via props:\n\n```svelte {runnable}\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination={true}\n pageSize={50}\n filterMode=\"menu\"\n/>\n```\n\n### Observable (callbacks fire when state changes)\n\nThe wrapper still owns the state, but emits callbacks on every change.\nUse this when an outside piece of UI needs to react (a \"X rows\nselected\" pill, a router that syncs sort to the URL, a server fetch).\n\n```svelte {runnable}\n<script lang=\"ts\">\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n onSortingChange={(next) => (sorting = next)}\n onFiltersChange={(next) => (filters = next.columns)}\n/>\n```\n\n### External (you own row ordering / filtering)\n\nFor server-side data or tree-structured data the wrapper records the\nsort + filter UI state but does **not** re-order the rows - you do.\nPair `externalSort` / `externalFilter` with the callbacks above:\n\n```svelte\n<SvGrid\n data={preFilteredRows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n externalSort={true}\n externalFilter={true}\n onSortingChange={(next) => fetchPage({ sort: next, page: 0 })}\n onFiltersChange={(next) => fetchPage({ filters: next.columns, page: 0 })}\n/>\n```\n\nFor Excel-style filter operators and the active-filter chip UI, see\n[`applyExcelFilter`](../packages/grid/src/filtering/excel-filters.ts).\n\n---\n\n## 10. Selection, editing, keyboard\n\n### Row selection\n\n```svelte {runnable}\n<script lang=\"ts\">\n let selected = $state<Person[]>([])\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n selectionMode=\"row\"\n showRowSelection={true}\n onRowSelectionChange={(_state, rows) => (selected = rows)}\n/>\n\n{#if selected.length}\n <p>{selected.length} selected</p>\n{/if}\n```\n\n`selectionMode` is the umbrella prop: `'row'` shows the checkbox\ncolumn, `'cell'` enables click-and-drag range selection,\n`'both'` (default) enables both, `'none'` disables both. The\n`onRowSelectionChange` callback receives the selection record AND the\nmaterialised row array.\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: turn the wrapper's `onSortingChange` /\n`onFiltersChange` callbacks into a query, fetch, hand the page back as\n`data`, and use the `externalSort` + `externalFilter` props so the grid\ndoesn't try to re-order rows it didn't fetch.\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature,\n columnFilteringFeature } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let sort = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n let page = $state(0)\n const pageSize = 50\n\n let rows = $state<Person[]>([])\n let total = $state(0)\n let loading = $state(false)\n let controller: AbortController | null = null\n\n async function load() {\n controller?.abort()\n controller = new AbortController()\n loading = true\n try {\n const res = await fetch('/api/people?' + new URLSearchParams({\n sort: JSON.stringify(sort),\n filters: JSON.stringify(filters),\n page: String(page),\n size: String(pageSize),\n }), { signal: controller.signal })\n const body = await res.json()\n rows = body.rows\n total = body.total\n } catch (err) {\n if ((err as Error).name !== 'AbortError') throw err\n } finally {\n loading = false\n }\n }\n\n $effect(() => { sort; filters; page; load() })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n externalSort={true}\n externalFilter={true}\n showPagination={false}\n onSortingChange={(next) => { sort = next; page = 0 }}\n onFiltersChange={(next) => { filters = next.columns; page = 0 }}\n/>\n\n<nav>\n <button onclick={() => (page = Math.max(0, page - 1))}\n disabled={page === 0 || loading}>‹ Prev</button>\n <span>Page {page + 1} of {Math.ceil(total / pageSize)}</span>\n <button onclick={() => (page = page + 1)}\n disabled={(page + 1) * pageSize >= total || loading}>Next ›</button>\n</nav>\n\n{#if loading}<div class=\"overlay\">Loading…</div>{/if}\n```\n\nThe `external*` props tell the grid not to re-derive that dimension\nlocally - the data you pass in is already the answer. Pagination above\nis hand-rolled so total-row-count and \"show next page\" stay in your\ncontrol; if a built-in pager is enough, leave `showPagination={true}`\non and the wrapper will page the local `rows` array (which, in this\nserver-side pattern, only ever holds one page anyway).\n\nSee the [`09-server-side` demo](../examples/src/demos/09-server-side.svelte)\nfor a complete runnable version with debounce, abort wiring, and a\n60 ms mock latency.\n\n---\n\n## 12. Virtualization for large datasets\n\nRow and column virtualization are both **on by default** - you opt out,\nnot in. `virtualization={false}` and `columnVirtualization={false}` render\nevery row / column, which is what you want for a short grid you intend to\nprint, or when sticky column pinning has to survive (the column virtualizer\nrecycles DOM nodes, so the two cannot co-exist).\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n rowHeight={36}\n overscan={6}\n/>\n```\n\n`rowHeight` takes a number (default 30) or a `(rowIndex) => px` function for\nper-row heights. When row content varies and you cannot predict it, set\n`autoRowHeight` instead and the grid measures each row. `overscan` (default 8)\nis how many rows beyond the viewport stay mounted.\n\nFor full control (e.g. variable row heights, programmatic scroll),\nuse the headless virtualizer directly:\n\n```ts\nimport { createSvelteVirtualizer } from '@svgrid/grid'\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/grid/src/virtualization/`](../packages/grid/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/grid/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 '@svgrid/grid'\n```\n\nThere is a contract test suite at\n[`a11y.contract.test.ts`](../packages/grid/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: GridColumns<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### Core features\n\n- **[Examples gallery](https://svgrid.com/demos/)** - 50+ 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- **[Tree rows (expand / collapse)](./help/rows/tree-rows.md)** - the\n flat-array + expanded-map pattern, connector lines, keyboard\n navigation, and lazy load on first expand.\n- **[Filter API](./help/filtering/filter-api.md)** - sort, filter,\n paginate, group, and aggregate locally or against your backend.\n- **[Tailwind integration](./help/tailwind.md)** - full list of CSS\n custom properties (`--sg-*`) and recipes for building your own theme.\n- **[Compare SvGrid with other Svelte data grids](https://svgrid.com/compare/)** -\n side-by-side feature matrix and when to pick which.\n\n### Enterprise features (`@svgrid/enterprise`)\n\nThe paid companion package augments your `SvGridApi` with one\n`installEnterprise(api)` call. Set a license key at app boot to remove the\n\"unlicensed\" watermark - every feature still runs without a key for\ndemos and evaluation.\n\n- **[Data export and printing](./help/export.md)** - Excel (xlsx), PDF,\n CSV, TSV, HTML, and a paginated print view. Defaults to the currently\n displayed rows so sort + filter + paginate carry through automatically.\n- **[Data import](./help/import.md)** - Excel (xlsx), CSV, TSV, and JSON\n with column mapping, per-row validation, and preview-before-commit. Auto-\n detects the format from the file extension or pasted text.\n- **[Pivot tables](./help/pivot.md)** - drag-and-drop Pivot Designer with\n Filters / Rows / Columns / Values zones, multi-level column headers,\n per-measure aggregator picker, pivot-aware sort. Built on the same\n engine; no special \"pivot mode\".\n- **[AI assistant](./help/ai.md)** - natural-language filter, smart fill,\n summarise, and classify, driven by a bring-your-own model adapter\n (`setAIProvider(fn)`). Ships with a deterministic `mockAIProvider` so\n the demo works without keys.\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 [@svgrid/mcp](https://svgrid.com/mcp/) server\n 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`@svgrid/grid` is published under the **MIT License**. Free for\ncommercial and personal use. The paid `@svgrid/enterprise` companion package\n(export, import, print, pivot, AI assistant) is governed by a separate\ncommercial license. See [LICENSE](../LICENSE) and\n[packages/enterprise/LICENSE](../packages/enterprise/LICENSE).\n"
3933
+ "markdown": "# Getting started: the complete guide on one page\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> `@svgrid/grid` is published under the **MIT License** - permissive\n> for commercial use, redistribution, and modification. The paid companion\n> `@svgrid/enterprise` (data export + print) ships under a separate commercial\n> license. See [LICENSE](../LICENSE) and\n> [packages/enterprise/LICENSE](../packages/enterprise/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\nEvery example below runs against this setup:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature, columnFilteringFeature, rowPaginationFeature, type ColumnDef } from '@svgrid/grid'\n\n type Person = { id: number; name: string; email: string; age: number; city: string }\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature, rowPaginationFeature })\n\n let rows = $state<Person[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', age: 36, city: 'London' },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', age: 45, city: 'New York' },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', age: 54, city: 'Portland' },\n ])\n const data = rows\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'name', header: 'Name' },\n { field: 'email', header: 'Email' },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'city', header: 'City' },\n ]\n</script>\n```\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { firstName: string; age: number; status: string }\n\n const rows: Person[] = [\n { firstName: 'Ada', age: 36, status: 'active' },\n { firstName: 'Linus', age: 54, status: 'active' },\n { firstName: 'Grace', age: 85, status: 'inactive' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First name' },\n { field: 'age', header: 'Age' },\n { field: 'status', header: 'Status' },\n ]\n</script>\n\n<SvGrid data={rows} columns={columns} />\n```\n\nThat's a complete, working grid. 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 @svgrid/grid\n\n# npm\nnpm install @svgrid/grid\n\n# yarn\nyarn add @svgrid/grid\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 features you want, and the\nmatching `ColumnDef` type:\n\n```ts\nimport {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature, type GridColumns,\n} from '@svgrid/grid'\n```\n\nThe bundle is tree-shakeable - features you don't import don't ship. The\ndefault render component (`<SvGrid>`) brings its own scoped CSS, so\nthere's no separate stylesheet to import. Re-theming happens via the\n`--sg-*` custom-property surface; see\n[Tailwind integration](./help/tailwind.md) for the full list.\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 '@svgrid/grid'\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.** Today the wrapper uses the row's array index as its id.\nThat is fine for read-only data; if you mutate `rows`, prefer keeping\nthe same object references for rows that didn't change so selection\nand edit state line up. A `getRowId` prop on the wrapper is tracked in\n[Missing features](./help/missing-features.md) and supported by the\nheadless `createSvGrid` core today.\n\n**Immutability.** 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 '@svgrid/grid'\n\ntype Person = {\n id: string\n firstName: string\n lastName: string\n age: number\n joinedAt: string // ISO date\n salary: number\n active: boolean\n}\n\nconst columns: GridColumns<Person> = [\n // `field`: read a key straight off the row\n { field: 'firstName', header: 'First name' },\n\n // `fieldFn`: compute the value; needs an explicit `id`\n {\n id: 'fullName',\n header: 'Full name',\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\n },\n\n // Numeric with locale-aware formatting\n {\n field: 'age',\n header: 'Age',\n format: { type: 'number', options: { maximumFractionDigits: 0 } },\n },\n\n // Date with explicit pattern\n {\n field: 'joinedAt',\n header: 'Joined',\n format: { type: 'date', pattern: 'y-m-d' },\n },\n\n // Currency\n {\n field: 'salary',\n header: 'Salary',\n format: { type: 'currency', currency: 'USD' },\n },\n\n // Boolean rendered as a checkbox\n {\n field: 'active',\n header: 'Active',\n editorType: 'checkbox',\n },\n]\n```\n\n**Common properties.**\n\n| Property | Purpose |\n| --- | --- |\n| `field` | Reads `row[key]`. |\n| `fieldFn` | Computes the value from the row. |\n| `id` | Stable column id (required if you use `fieldFn`). |\n| `header` | String or render snippet for the header. |\n| `footer` | String or render snippet for the footer row. |\n| `cell` | Render snippet/component for the body cell. |\n| `format` | Locale-aware formatter (`number`, `currency`, `percent`, `date`). |\n| `formatter` | Function for one-off custom value formatting. |\n| `editorType` | Inline editor: `text` \\| `number` \\| `checkbox` \\| `date` \\| `datetime`. |\n| `width` | Initial column width in pixels (default `columnWidth` prop). |\n| `align` | Header + body alignment: `'left'` \\| `'right'` \\| `'center'`. Inferred from `editorType` when omitted. |\n| `columns` | Child column defs (for column groups). |\n\nSorting / filtering / grouping are toggled per-grid via the registered\nfeatures - there is no per-column `enableSorting` / `enableColumnFilter`\nflag yet; those entries are in [Missing features](./help/missing-features.md).\n\nSee [`packages/grid/src/core.ts`](../packages/grid/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 rowSelectionFeature, type GridColumns,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination={true}\n pageSize={25}\n/>\n```\n\n**Rule of thumb.** Only register the features you use. The wrapper wires\nthe matching row-model pipeline (core → filtered → sorted → grouped →\nexpanded) for you and exposes the user-facing toggles via props\n(`showPagination`, `filterMode`, `showRowSelection`, …). If you need the\nheadless pipeline directly - e.g. a custom renderer - drop down to\n`createSvGrid` from the same package; see [Why headless?](./why-headless.md).\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's render component (`<SvGrid>`) ships its own scoped styles. You\nre-theme it by declaring `--sg-*` custom properties at any level above\nthe grid - the included light, dark, and high-contrast palettes in the\n[`10-custom-cells-and-themes`](../examples/src/demos/10-custom-cells-and-themes.svelte)\ndemo are themselves just three sets of these tokens applied via\n`style=\"--sg-bg: …; --sg-fg: …; …\"`.\n\n**Customising tokens.** Override at any level - `:root`, a wrapper, or\ndirectly on `<SvGrid>`.\n\n```css\n:root {\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.** Row height is the one piece of the look that is a prop\nrather than a token - the virtualizer needs it as a number, and the\ngrid writes it as an inline style on each row. Pass `rowHeight`\n(default `30`): `28` for compact, `48` for comfortable. Changing it\ndoes not remount 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\nThe grid's scroll shell is sized by `containerHeight`, which defaults to\n520 px. A number is pixels; a string is passed through to CSS, so `'100%'`\nfills the parent and `'auto'` grows to the content.\n\n```svelte {runnable}\n<!-- Fixed: 600px tall. The typical choice. -->\n<SvGrid data={rows} columns={columns} containerHeight={600} />\n\n<!-- Flexible: fills a parent that sets the height itself. -->\n<div class=\"grid-shell\">\n <SvGrid data={rows} columns={columns} containerHeight=\"100%\" />\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. Turn row virtualization\noff as well - a shell with no fixed height gives the virtualizer nothing\nto measure against:\n\n```svelte {runnable}\n<SvGrid\n data={rows}\n columns={columns}\n containerHeight=\"auto\"\n virtualization={false}\n/>\n```\n\nDon't use this for large data: every row renders.\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 GridColumns } from '@svgrid/grid'\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: GridColumns<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 '@svgrid/grid'\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**Quick way - capability shortcuts.** Every capability is off by default;\nthe fastest way to opt in is a boolean shortcut prop, no feature constants\nrequired. `sortable` and `filterable` inject the matching feature for you;\n`editable`, `groupable`, and `pageable` alias `enableInlineEditing`,\n`showGroupingControls`, and `showPagination`:\n\n```svelte {runnable}\n<SvGrid data={rows} columns={columns} sortable filterable editable groupable pageable />\n```\n\nReach for the explicit `features` set + fine-grained props below when you\nneed more control (filter mode, page size, per-column opt-outs).\n\n### Uncontrolled (the default)\n\nThe wrapper owns sort, filter, pagination, selection, and expansion\nstate by default. Set the initial page size and which filter UI to\nshow via props:\n\n```svelte {runnable}\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination={true}\n pageSize={50}\n filterMode=\"menu\"\n/>\n```\n\n### Observable (callbacks fire when state changes)\n\nThe wrapper still owns the state, but emits callbacks on every change.\nUse this when an outside piece of UI needs to react (a \"X rows\nselected\" pill, a router that syncs sort to the URL, a server fetch).\n\n```svelte {runnable}\n<script lang=\"ts\">\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n onSortingChange={(next) => (sorting = next)}\n onFiltersChange={(next) => (filters = next.columns)}\n/>\n```\n\n### External (you own row ordering / filtering)\n\nFor server-side data or tree-structured data the wrapper records the\nsort + filter UI state but does **not** re-order the rows - you do.\nPair `externalSort` / `externalFilter` with the callbacks above:\n\n```svelte\n<SvGrid\n data={preFilteredRows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n externalSort={true}\n externalFilter={true}\n onSortingChange={(next) => fetchPage({ sort: next, page: 0 })}\n onFiltersChange={(next) => fetchPage({ filters: next.columns, page: 0 })}\n/>\n```\n\nFor Excel-style filter operators and the active-filter chip UI, see\n[`applyExcelFilter`](../packages/grid/src/filtering/excel-filters.ts).\n\n---\n\n## 10. Selection, editing, keyboard\n\n### Row selection\n\n```svelte {runnable}\n<script lang=\"ts\">\n let selected = $state<Person[]>([])\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n selectionMode=\"row\"\n showRowSelection={true}\n onRowSelectionChange={(_state, rows) => (selected = rows)}\n/>\n\n{#if selected.length}\n <p>{selected.length} selected</p>\n{/if}\n```\n\n`selectionMode` is the umbrella prop: `'row'` shows the checkbox\ncolumn, `'cell'` enables click-and-drag range selection,\n`'both'` (default) enables both, `'none'` disables both. The\n`onRowSelectionChange` callback receives the selection record AND the\nmaterialised row array.\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: turn the wrapper's `onSortingChange` /\n`onFiltersChange` callbacks into a query, fetch, hand the page back as\n`data`, and use the `externalSort` + `externalFilter` props so the grid\ndoesn't try to re-order rows it didn't fetch.\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, rowSortingFeature,\n columnFilteringFeature } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\n\n let sort = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n let page = $state(0)\n const pageSize = 50\n\n let rows = $state<Person[]>([])\n let total = $state(0)\n let loading = $state(false)\n let controller: AbortController | null = null\n\n async function load() {\n controller?.abort()\n controller = new AbortController()\n loading = true\n try {\n const res = await fetch('/api/people?' + new URLSearchParams({\n sort: JSON.stringify(sort),\n filters: JSON.stringify(filters),\n page: String(page),\n size: String(pageSize),\n }), { signal: controller.signal })\n const body = await res.json()\n rows = body.rows\n total = body.total\n } catch (err) {\n if ((err as Error).name !== 'AbortError') throw err\n } finally {\n loading = false\n }\n }\n\n $effect(() => { sort; filters; page; load() })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n externalSort={true}\n externalFilter={true}\n showPagination={false}\n onSortingChange={(next) => { sort = next; page = 0 }}\n onFiltersChange={(next) => { filters = next.columns; page = 0 }}\n/>\n\n<nav>\n <button onclick={() => (page = Math.max(0, page - 1))}\n disabled={page === 0 || loading}>‹ Prev</button>\n <span>Page {page + 1} of {Math.ceil(total / pageSize)}</span>\n <button onclick={() => (page = page + 1)}\n disabled={(page + 1) * pageSize >= total || loading}>Next ›</button>\n</nav>\n\n{#if loading}<div class=\"overlay\">Loading…</div>{/if}\n```\n\nThe `external*` props tell the grid not to re-derive that dimension\nlocally - the data you pass in is already the answer. Pagination above\nis hand-rolled so total-row-count and \"show next page\" stay in your\ncontrol; if a built-in pager is enough, leave `showPagination={true}`\non and the wrapper will page the local `rows` array (which, in this\nserver-side pattern, only ever holds one page anyway).\n\nSee the [`09-server-side` demo](../examples/src/demos/09-server-side.svelte)\nfor a complete runnable version with debounce, abort wiring, and a\n60 ms mock latency.\n\n---\n\n## 12. Virtualization for large datasets\n\nRow and column virtualization are both **on by default** - you opt out,\nnot in. `virtualization={false}` and `columnVirtualization={false}` render\nevery row / column, which is what you want for a short grid you intend to\nprint, or when sticky column pinning has to survive (the column virtualizer\nrecycles DOM nodes, so the two cannot co-exist).\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid } from '@svgrid/grid'\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n rowHeight={36}\n overscan={6}\n/>\n```\n\n`rowHeight` takes a number (default 30) or a `(rowIndex) => px` function for\nper-row heights. When row content varies and you cannot predict it, set\n`autoRowHeight` instead and the grid measures each row. `overscan` (default 8)\nis how many rows beyond the viewport stay mounted.\n\nFor full control (e.g. variable row heights, programmatic scroll),\nuse the headless virtualizer directly:\n\n```ts\nimport { createSvelteVirtualizer } from '@svgrid/grid'\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/grid/src/virtualization/`](../packages/grid/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/grid/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 '@svgrid/grid'\n```\n\nThere is a contract test suite at\n[`a11y.contract.test.ts`](../packages/grid/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: GridColumns<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### Core features\n\n- **[Examples gallery](https://svgrid.com/demos/)** - 50+ 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- **[Tree rows (expand / collapse)](./help/rows/tree-rows.md)** - the\n flat-array + expanded-map pattern, connector lines, keyboard\n navigation, and lazy load on first expand.\n- **[Filter API](./help/filtering/filter-api.md)** - sort, filter,\n paginate, group, and aggregate locally or against your backend.\n- **[Tailwind integration](./help/tailwind.md)** - full list of CSS\n custom properties (`--sg-*`) and recipes for building your own theme.\n- **[Compare SvGrid with other Svelte data grids](https://svgrid.com/compare/)** -\n side-by-side feature matrix and when to pick which.\n\n### Enterprise features (`@svgrid/enterprise`)\n\nThe paid companion package augments your `SvGridApi` with one\n`installEnterprise(api)` call. Set a license key at app boot to remove the\n\"unlicensed\" watermark - every feature still runs without a key for\ndemos and evaluation.\n\n- **[Data export and printing](./help/export.md)** - Excel (xlsx), PDF,\n CSV, TSV, HTML, and a paginated print view. Defaults to the currently\n displayed rows so sort + filter + paginate carry through automatically.\n- **[Data import](./help/import.md)** - Excel (xlsx), CSV, TSV, and JSON\n with column mapping, per-row validation, and preview-before-commit. Auto-\n detects the format from the file extension or pasted text.\n- **[Pivot tables](./help/pivot.md)** - drag-and-drop Pivot Designer with\n Filters / Rows / Columns / Values zones, multi-level column headers,\n per-measure aggregator picker, pivot-aware sort. Built on the same\n engine; no special \"pivot mode\".\n- **[AI assistant](./help/ai.md)** - natural-language filter, smart fill,\n summarise, and classify, driven by a bring-your-own model adapter\n (`setAIProvider(fn)`). Ships with a deterministic `mockAIProvider` so\n the demo works without keys.\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 [@svgrid/mcp](https://svgrid.com/mcp/) server\n 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`@svgrid/grid` is published under the **MIT License**. Free for\ncommercial and personal use. The paid `@svgrid/enterprise` companion package\n(export, import, print, pivot, AI assistant) is governed by a separate\ncommercial license. See [LICENSE](../LICENSE) and\n[packages/enterprise/LICENSE](../packages/enterprise/LICENSE).\n"
3926
3934
  },
3927
3935
  {
3928
3936
  "slug": "getting-started",
@@ -3943,7 +3951,7 @@ export const docs = [
3943
3951
  "path": "docs/getting-started/2-first-grid.md",
3944
3952
  "title": "2. First grid in 60 seconds",
3945
3953
  "section": "Getting started",
3946
- "markdown": "# 2. First grid in 60 seconds\n\n> Step 2 of 6 · [← Install](./1-install.md) · [Next: Data and columns →](./3-data-and-columns.md)\n\n![Anatomy of the minimal example: a data rows array and a columns ColumnDef array flow into a SvGrid element that renders a small table with a header row and three body rows.](/docs-media/gs-first-grid.svg)\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { firstName: string; age: number; status: string }\n\n const rows: Person[] = [\n { firstName: 'Ada', age: 36, status: 'active' },\n { firstName: 'Linus', age: 54, status: 'active' },\n { firstName: 'Grace', age: 85, status: 'inactive' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First name' },\n { field: 'age', header: 'Age' },\n { field: 'status', header: 'Status' },\n ]\n</script>\n\n<SvGrid data={rows} columns={columns} />\n```\n\nThat's a complete, working grid.\n\n<!-- tutorial:learn-1-first-grid -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-1-first-grid\" data-docs-tutorial=\"learn-1-first-grid\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-1-first-grid.mp4\" poster=\"/tutorials/learn-1-first-grid.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 1: install and your first grid, 121 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-1-first-grid.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-1-first-grid.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 1: install and your first grid</strong> (2:01, silent). <a href=\"https://www.youtube.com/watch?v=s7NHVtLQaOs\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Welcome to lesson one. By the end of it you will have a working data grid in a Svelte 5 project, and you will understand every line that put it there.</p>\n<p>SvGrid is a single npm package. There is no CLI step and nothing to copy into your project: you install it and import from it.</p>\n<p>It needs Svelte 5, because the grid is built on runes. If you are starting from nothing, the create command scaffolds a project with the grid already wired up.</p>\n<p>Now the component. Import SvGrid, and import the GridColumns type as well. That type is what makes the column list check itself against your rows.</p>\n<p>Your data is a plain array of plain objects. No store, no wrapper, no adapter. If you already have rows in your app, they are already in the right shape.</p>\n<p>Columns are the other half. Each one maps a field on your row to a header the user reads. Because the array is typed as GridColumns of Person, a typo in a field name is a compile error, not a blank column.</p>\n<p>Then render the component with both. That is the whole API for a basic grid: data in, columns in.</p>\n<p>Save, and the dev server shows this. A real table element, with a header row and your three rows in it.</p>\n<p>You did not ask for any of this, but you got it: click a cell and it takes focus, arrow keys move between cells, Home and End jump to the ends of a row. That is the ARIA grid pattern, on by default.</p>\n<p>What you do not have yet is sorting, filtering or paging. Those are switched on deliberately, so a grid you never configured stays small in your bundle. Lesson two puts your own data in, and lesson three turns those features on.</p>\n<p>The code from this lesson is on the page linked below, and it runs there too. See you in lesson two.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-1-first-grid -->\n\n## What you got out of the box\n\n- A semantic `<table>` with WAI-ARIA `role=\"grid\"` / `role=\"row\"` /\n `role=\"columnheader\"` / `role=\"gridcell\"` on every node.\n- Keyboard navigation: arrow keys move the active cell, Home/End jump\n to row edges, Page Up/Down move by a page, Ctrl+Home / Ctrl+End jump\n to the grid corners.\n- A focus ring on the active cell. Selection on click. F2 / double-click\n to edit (no-op here because no column has `editorType`).\n- Auto-sized columns; explicit `width` on the column def overrides.\n- The default `--sg-*` theme: borders, header background, zebra rows,\n hover, selection - all on tokens you can swap.\n\n## What you didn't get yet\n\nSort, filter, pagination, grouping, expansion, selection are **off**\nuntil you register the matching features. That's the next step.\n\n```svelte\n<!-- after registering rowSortingFeature + columnFilteringFeature -->\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n/>\n```\n\n[Step 3 →](./3-data-and-columns.md) covers how `data` and `columns`\nwork in detail; [step 4 →](./4-features.md) lights up everything else.\n\n## See it run\n\nThe quick-start demo is a slightly fancier version (more columns,\ninline editing, range selection):\n\n<div data-docs-demo=\"01-quick-start\" data-height=\"440\"></div>\n\nSource: [examples/src/demos/01-quick-start.svelte](../../examples/src/demos/01-quick-start.svelte).\n\n## Adding the three common features\n\nEach capability is one boolean. Turn on what the screen needs and nothing\nelse ships - which is why the first grid stays small.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={seed} {columns} sortable filterable pageable pageSize={2} />\n```\n"
3954
+ "markdown": "\n# 2. First grid in 60 seconds\n\n> Step 2 of 6 · [← Install](./1-install.md) · [Next: Data and columns →](./3-data-and-columns.md)\n\n![Anatomy of the minimal example: a data rows array and a columns ColumnDef array flow into a SvGrid element that renders a small table with a header row and three body rows.](/docs-media/gs-first-grid.svg)\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { firstName: string; age: number; status: string }\n\n const rows: Person[] = [\n { firstName: 'Ada', age: 36, status: 'active' },\n { firstName: 'Linus', age: 54, status: 'active' },\n { firstName: 'Grace', age: 85, status: 'inactive' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'firstName', header: 'First name' },\n { field: 'age', header: 'Age' },\n { field: 'status', header: 'Status' },\n ]\n</script>\n\n<SvGrid data={rows} columns={columns} />\n```\n\nThat's a complete, working grid.\n\n<!-- tutorial:learn-1-first-grid -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-1-first-grid\" data-docs-tutorial=\"learn-1-first-grid\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-1-first-grid.mp4\" poster=\"/tutorials/learn-1-first-grid.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 1: install and your first grid, 121 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-1-first-grid.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-1-first-grid.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 1: install and your first grid</strong> (2:01, silent). <a href=\"https://www.youtube.com/watch?v=s7NHVtLQaOs\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>Welcome to lesson one. By the end of it you will have a working data grid in a Svelte 5 project, and you will understand every line that put it there.</p>\n<p>SvGrid is a single npm package. There is no CLI step and nothing to copy into your project: you install it and import from it.</p>\n<p>It needs Svelte 5, because the grid is built on runes. If you are starting from nothing, the create command scaffolds a project with the grid already wired up.</p>\n<p>Now the component. Import SvGrid, and import the GridColumns type as well. That type is what makes the column list check itself against your rows.</p>\n<p>Your data is a plain array of plain objects. No store, no wrapper, no adapter. If you already have rows in your app, they are already in the right shape.</p>\n<p>Columns are the other half. Each one maps a field on your row to a header the user reads. Because the array is typed as GridColumns of Person, a typo in a field name is a compile error, not a blank column.</p>\n<p>Then render the component with both. That is the whole API for a basic grid: data in, columns in.</p>\n<p>Save, and the dev server shows this. A real table element, with a header row and your three rows in it.</p>\n<p>You did not ask for any of this, but you got it: click a cell and it takes focus, arrow keys move between cells, Home and End jump to the ends of a row. That is the ARIA grid pattern, on by default.</p>\n<p>What you do not have yet is sorting, filtering or paging. Those are switched on deliberately, so a grid you never configured stays small in your bundle. Lesson two puts your own data in, and lesson three turns those features on.</p>\n<p>The code from this lesson is on the page linked below, and it runs there too. See you in lesson two.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-1-first-grid -->\n\n## What you got out of the box\n\n- A semantic `<table>` with WAI-ARIA `role=\"grid\"` / `role=\"row\"` /\n `role=\"columnheader\"` / `role=\"gridcell\"` on every node.\n- Keyboard navigation: arrow keys move the active cell, Home/End jump\n to row edges, Page Up/Down move by a page, Ctrl+Home / Ctrl+End jump\n to the grid corners.\n- A focus ring on the active cell. Selection on click. F2 / double-click\n to edit (no-op here because no column has `editorType`).\n- Auto-sized columns; explicit `width` on the column def overrides.\n- The default `--sg-*` theme: borders, header background, zebra rows,\n hover, selection - all on tokens you can swap.\n\n## What you didn't get yet\n\nSort, filter, pagination, grouping, expansion, selection are **off**\nuntil you register the matching features. That's the next step.\n\n```svelte\n<!-- after registering rowSortingFeature + columnFilteringFeature -->\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n/>\n```\n\n[Step 3 →](./3-data-and-columns.md) covers how `data` and `columns`\nwork in detail; [step 4 →](./4-features.md) lights up everything else.\n\n## See it run\n\nThe quick-start demo is a slightly fancier version (more columns,\ninline editing, range selection):\n\n<div data-docs-demo=\"01-quick-start\" data-height=\"440\"></div>\n\nSource: [examples/src/demos/01-quick-start.svelte](../../examples/src/demos/01-quick-start.svelte).\n\n## Adding the three common features\n\nEach capability is one boolean. Turn on what the screen needs and nothing\nelse ships - which is why the first grid stays small.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n email: string\n city: string\n age: number\n salary: number\n }\n\n const seed: Person[] = [\n { id: 1, name: 'Ada Lovelace', email: 'ada@example.com', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', email: 'grace@example.com', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', email: 'linus@example.com', city: 'Portland', age: 54, salary: 155000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 140,\n format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n\n<SvGrid data={seed} {columns} sortable filterable pageable pageSize={2} />\n```\n"
3947
3955
  },
3948
3956
  {
3949
3957
  "slug": "getting-started/3-data-and-columns",
@@ -3964,7 +3972,7 @@ export const docs = [
3964
3972
  "path": "docs/getting-started/5-theme-and-density.md",
3965
3973
  "title": "5. Theme and density",
3966
3974
  "section": "Getting started",
3967
- "markdown": "# 5. Theme and density\n\n> Step 5 of 6 · [← Features](./4-features.md) · [Next: Going to production →](./6-going-to-production.md)\n\n## Start with a preset\n\nBefore hand-writing any tokens: 20 design-system presets ship with the package,\neach a single stylesheet with a full light + dark palette. One import re-themes\nthe whole grid.\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nimport '@svgrid/grid/themes/shadcn.css'\n```\n\nAvailable: `ember` (SvGrid's own look), `shadcn`, `tailwind`, `material`,\n`fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`, `salesforce`, `sap`,\n`github`, `linear`, `notion`, `vercel`, `excel`, `nord`, `dracula`,\n`catppuccin`, `ag-alpine`.\n\nEach preset defines every token the grid and the UI components read - including\nthe semantic status colors (`--sg-danger`, `--sg-success`, `--sg-warning`,\n`--sg-info`) and the focus ring, which follows the preset's accent. Presets\nflip with `data-theme=\"dark\"` automatically (see below).\n\nOverride individual tokens after the import to adjust a preset, or skip presets\nentirely and declare the tokens yourself - that is the rest of this page.\n\n## Declaring tokens yourself\n\nThe render component (`<SvGrid>`) ships its own scoped styles. You\nre-theme it by declaring `--sg-*` CSS custom properties at any level\nabove the grid - `:root` for the whole app, a wrapper `<div>` for one\ninstance, or directly on the `<SvGrid>` element itself.\n\n![The --sg-* CSS custom properties declared at :root, a wrapper div, or the SvGrid element cascade into the grid, controlling light and dark themes and comfortable versus compact row density.](/docs-media/gs-theming.svg)\n\n## Token surface\n\nThe 20-odd tokens the renderer reads:\n\n| Token | What it paints |\n| -------------------------------- | ------------------------------------------- |\n| `--sg-bg` | Cell background |\n| `--sg-fg` | Cell text |\n| `--sg-muted` | Secondary text (footers, subtitles) |\n| `--sg-border` | Cell + header borders |\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\n| `--sg-row-alt-bg` | Zebra rows |\n| `--sg-row-hover-bg` | Row + cell hover |\n| `--sg-selection-bg` | Selected cell / row tint |\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\n| `--sg-focus-ring` | Keyboard focus outline |\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\n| `--sg-pill-active` / `-fg` | \"Active\" status pills |\n| `--sg-pill-pending` / `-fg` | \"Pending\" status pills |\n| `--sg-pill-inactive` / `-fg` | \"Inactive\" status pills |\n| `--sg-scrollbar-*` (10 tokens) | Custom-painted scrollbars |\n\n## Light + dark via `data-theme`\n\nThe gallery flips themes by writing `dark` or `light` to\n`html[data-theme]`. Every token redeclares under that selector:\n\n```css\n:root {\n --sg-bg: #ffffff;\n --sg-fg: #0f172a;\n --sg-border: #e2e8f0;\n --sg-header-bg: #f1f5f9;\n --sg-row-alt-bg: #f8fafc;\n --sg-row-hover-bg: #eef2ff;\n --sg-accent: #2563eb;\n}\n\nhtml[data-theme='dark'] {\n --sg-bg: #0f172a;\n --sg-fg: #f1f5f9;\n --sg-border: #334155;\n --sg-header-bg: #1e2433;\n --sg-row-alt-bg: #1b2230;\n --sg-row-hover-bg: #232b3c;\n --sg-accent: #3b82f6;\n color-scheme: dark;\n}\n```\n\nToggling is one line in the app shell:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let theme = $state<'light' | 'dark'>('dark')\n $effect(() => document.documentElement.setAttribute('data-theme', theme))\n</script>\n\n<button onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}>\n Toggle theme\n</button>\n```\n\n## Per-instance theming\n\nBecause the tokens are plain custom properties they cascade. To style a\nsingle grid, wrap it in a `<div>` that sets its own values:\n\n```svelte\n<div style=\"--sg-bg: #fff8f0; --sg-accent: #db2777;\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\nThe [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte)\ndemo applies three full palettes (light / dark / high-contrast) this way.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"520\"></div>\n\n## Density\n\nDensity is a **prop, not a token**. The virtualizer has to know each\nrow's height as a number before it can position rows, so it cannot be\nresolved from CSS - the grid writes the height as an inline style on\nevery row, which would override a stylesheet rule anyway.\n\nSet `rowHeight` on `<SvGrid>`. Numeric, in pixels; drives the row\nheight and the active-cell hit box. The default is `30`.\n\n```svelte\n<SvGrid {data} {columns} features={features} rowHeight={28} />\n```\n\nCommon steps: `28` compact, `30` normal, `46`-`48` comfortable. For\nrows whose height depends on their content, set `autoRowHeight`\ninstead and the grid measures each row.\n\nA user-facing \"density selector\" is half a dozen lines:\n\n```svelte\n<script lang=\"ts\">\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\n const height = $derived(\n density === 'compact' ? 28 : density === 'comfortable' ? 48 : 30,\n )\n</script>\n\n<select bind:value={density}>\n <option value=\"compact\">Compact</option>\n <option value=\"normal\">Normal</option>\n <option value=\"comfortable\">Comfortable</option>\n</select>\n\n<SvGrid {data} {columns} features={features} rowHeight={height} />\n```\n\n## Sizing the grid\n\nThe wrapper renders inside whatever container you give it. The\n`containerHeight` prop sets the scrollable shell height:\n\n```svelte\n<!-- Numeric: px -->\n<SvGrid {data} {columns} features={features} containerHeight={520} />\n\n<!-- String: passed through to CSS -->\n<SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n<SvGrid {data} {columns} features={features} containerHeight=\"auto\" />\n```\n\nFor a flex-grow layout the canonical recipe is:\n\n```svelte\n<div class=\"flex flex-col h-screen\">\n <header>…</header>\n <div class=\"flex-1 min-h-0\">\n <SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n </div>\n</div>\n```\n\nThe `min-h-0` is the bit that bites. Flex children default to\n`min-height: auto`, which prevents the inner scroll container from\nshrinking, which makes the whole page scroll instead of the grid.\n\n## Full Tailwind integration\n\nIf your app uses Tailwind, see [Tailwind integration](../help/tailwind.md)\nfor: install + PostCSS config, `@custom-variant` so the `dark:`\nmodifier follows `data-theme`, the override hooks for the stable\n`.sv-grid-*` class names, and the anti-patterns (don't `@apply` inside\ngrid selectors, don't put utility classes on grid children, don't\nfight column widths in CSS).\n\n## Try it\n\nThere is no `density` prop - row height is the density control, and it is one\nnumber. Compact for a screen someone scans all day, comfortable for one they\nread.\n\n```svelte {runnable}\n<SvGrid data={people} {columns} rowHeight={28} showRowNumbers />\n\n<SvGrid data={people} {columns} rowHeight={48} showRowNumbers />\n```\n\n## Re-theming with tokens\n\nThe grid reads `--sg-*` custom properties, so a theme is a block of variables on\nany ancestor - no theme file, no build step.\n\n```svelte {runnable}\n<div class=\"midnight\">\n <SvGrid data={people} {columns} sortable />\n</div>\n\n<style>\n .midnight {\n --sg-bg: #0f172a;\n --sg-fg: #e2e8f0;\n --sg-border: #1e293b;\n --sg-header-bg: #111c33;\n --sg-header-fg: #93c5fd;\n --sg-accent: #38bdf8;\n --sg-row-hover: #16233d;\n }\n</style>\n```\n\n<!-- tutorial:learn-7-theme -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-7-theme\" data-docs-tutorial=\"learn-7-theme\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-7-theme.mp4\" poster=\"/tutorials/learn-7-theme.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 7: themes, dark mode and density, 113 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-7-theme.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-7-theme.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 7: themes, dark mode and density</strong> (1:53, silent). <a href=\"https://www.youtube.com/watch?v=yhJMpkQ0Kww\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>A grid that looks like a grid library is a grid that never ships. Theming here is CSS custom properties, not utility classes and not a config object.</p>\n<p>Twenty presets ship with the package, and importing one stylesheet is the whole setup. What a preset sets is a list of custom properties, which means you can set them yourself when one is close but not right. Same five people as before.</p>\n<p>A wrapper, and a handful of properties on it. Background, text, border, the header colours, the accent, the hover. They cascade like any other custom property, so this theme is scoped to this one grid rather than the page.</p>\n<p>The whole file, so nothing is hidden: the rows, the columns, a wrapper around the grid, and the properties on that wrapper.</p>\n<p>Here is that file running. Nine declarations, and nothing about the grid was configured to accept them.</p>\n<p>Density is separate from colour, and it is the setting people actually argue about. Row height is a prop, so the compact table and the comfortable one are the same component with one number changed.</p>\n<p>Dark mode is not a second stylesheet you maintain. Every preset declares a light palette and a dark one, and an attribute on the document element picks which is live. Every surface follows, including the parts you never styled.</p>\n<p>The density control here is the same row height prop behind a dropdown, which is what it usually becomes: a preference you save per user rather than a decision you make once for everybody.</p>\n<p>The theme builder on the site edits these tokens live and hands you the CSS at the end. Every property is listed on the page below. See you in lesson eight.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-7-theme -->\n"
3975
+ "markdown": "# 5. Theme and density\n\n> Step 5 of 6 · [← Features](./4-features.md) · [Next: Going to production →](./6-going-to-production.md)\n\n## Start with a preset\n\nBefore hand-writing any tokens: 20 design-system presets ship with the package,\neach a single stylesheet with a full light + dark palette. One import re-themes\nthe whole grid.\n\n> Designing in Figma? The [SvGrid Design Kit](https://www.figma.com/community/file/1689644521333526144)\n> has the Ember and shadcn/ui presets as variable modes, with every color named\n> after its `--sg-*` token. See [Figma design kit](../help/figma.md).\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 130 },\n { field: 'age', header: 'Age', width: 80 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nimport '@svgrid/grid/themes/shadcn.css'\n```\n\nAvailable: `ember` (SvGrid's own look), `shadcn`, `tailwind`, `material`,\n`fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`, `salesforce`, `sap`,\n`github`, `linear`, `notion`, `vercel`, `excel`, `nord`, `dracula`,\n`catppuccin`, `ag-alpine`.\n\nEach preset defines every token the grid and the UI components read - including\nthe semantic status colors (`--sg-danger`, `--sg-success`, `--sg-warning`,\n`--sg-info`) and the focus ring, which follows the preset's accent. Presets\nflip with `data-theme=\"dark\"` automatically (see below).\n\nOverride individual tokens after the import to adjust a preset, or skip presets\nentirely and declare the tokens yourself - that is the rest of this page.\n\n## Declaring tokens yourself\n\nThe render component (`<SvGrid>`) ships its own scoped styles. You\nre-theme it by declaring `--sg-*` CSS custom properties at any level\nabove the grid - `:root` for the whole app, a wrapper `<div>` for one\ninstance, or directly on the `<SvGrid>` element itself.\n\n![The --sg-* CSS custom properties declared at :root, a wrapper div, or the SvGrid element cascade into the grid, controlling light and dark themes and comfortable versus compact row density.](/docs-media/gs-theming.svg)\n\n## Token surface\n\nThe 20-odd tokens the renderer reads:\n\n| Token | What it paints |\n| -------------------------------- | ------------------------------------------- |\n| `--sg-bg` | Cell background |\n| `--sg-fg` | Cell text |\n| `--sg-muted` | Secondary text (footers, subtitles) |\n| `--sg-border` | Cell + header borders |\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\n| `--sg-row-alt-bg` | Zebra rows |\n| `--sg-row-hover-bg` | Row + cell hover |\n| `--sg-selection-bg` | Selected cell / row tint |\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\n| `--sg-focus-ring` | Keyboard focus outline |\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\n| `--sg-pill-active` / `-fg` | \"Active\" status pills |\n| `--sg-pill-pending` / `-fg` | \"Pending\" status pills |\n| `--sg-pill-inactive` / `-fg` | \"Inactive\" status pills |\n| `--sg-scrollbar-*` (10 tokens) | Custom-painted scrollbars |\n\n## Light + dark via `data-theme`\n\nThe gallery flips themes by writing `dark` or `light` to\n`html[data-theme]`. Every token redeclares under that selector:\n\n```css\n:root {\n --sg-bg: #ffffff;\n --sg-fg: #0f172a;\n --sg-border: #e2e8f0;\n --sg-header-bg: #f1f5f9;\n --sg-row-alt-bg: #f8fafc;\n --sg-row-hover-bg: #eef2ff;\n --sg-accent: #2563eb;\n}\n\nhtml[data-theme='dark'] {\n --sg-bg: #0f172a;\n --sg-fg: #f1f5f9;\n --sg-border: #334155;\n --sg-header-bg: #1e2433;\n --sg-row-alt-bg: #1b2230;\n --sg-row-hover-bg: #232b3c;\n --sg-accent: #3b82f6;\n color-scheme: dark;\n}\n```\n\nToggling is one line in the app shell:\n\n```svelte {runnable}\n<script lang=\"ts\">\n let theme = $state<'light' | 'dark'>('dark')\n $effect(() => document.documentElement.setAttribute('data-theme', theme))\n</script>\n\n<button onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}>\n Toggle theme\n</button>\n```\n\n## Per-instance theming\n\nBecause the tokens are plain custom properties they cascade. To style a\nsingle grid, wrap it in a `<div>` that sets its own values:\n\n```svelte\n<div style=\"--sg-bg: #fff8f0; --sg-accent: #db2777;\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\nThe [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte)\ndemo applies three full palettes (light / dark / high-contrast) this way.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"520\"></div>\n\n## Density\n\nDensity is a **prop, not a token**. The virtualizer has to know each\nrow's height as a number before it can position rows, so it cannot be\nresolved from CSS - the grid writes the height as an inline style on\nevery row, which would override a stylesheet rule anyway.\n\nSet `rowHeight` on `<SvGrid>`. Numeric, in pixels; drives the row\nheight and the active-cell hit box. The default is `30`.\n\n```svelte\n<SvGrid {data} {columns} features={features} rowHeight={28} />\n```\n\nCommon steps: `28` compact, `30` normal, `46`-`48` comfortable. For\nrows whose height depends on their content, set `autoRowHeight`\ninstead and the grid measures each row.\n\nA user-facing \"density selector\" is half a dozen lines:\n\n```svelte\n<script lang=\"ts\">\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\n const height = $derived(\n density === 'compact' ? 28 : density === 'comfortable' ? 48 : 30,\n )\n</script>\n\n<select bind:value={density}>\n <option value=\"compact\">Compact</option>\n <option value=\"normal\">Normal</option>\n <option value=\"comfortable\">Comfortable</option>\n</select>\n\n<SvGrid {data} {columns} features={features} rowHeight={height} />\n```\n\n## Sizing the grid\n\nThe wrapper renders inside whatever container you give it. The\n`containerHeight` prop sets the scrollable shell height:\n\n```svelte\n<!-- Numeric: px -->\n<SvGrid {data} {columns} features={features} containerHeight={520} />\n\n<!-- String: passed through to CSS -->\n<SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n<SvGrid {data} {columns} features={features} containerHeight=\"auto\" />\n```\n\nFor a flex-grow layout the canonical recipe is:\n\n```svelte\n<div class=\"flex flex-col h-screen\">\n <header>…</header>\n <div class=\"flex-1 min-h-0\">\n <SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\n </div>\n</div>\n```\n\nThe `min-h-0` is the bit that bites. Flex children default to\n`min-height: auto`, which prevents the inner scroll container from\nshrinking, which makes the whole page scroll instead of the grid.\n\n## Full Tailwind integration\n\nIf your app uses Tailwind, see [Tailwind integration](../help/tailwind.md)\nfor: install + PostCSS config, `@custom-variant` so the `dark:`\nmodifier follows `data-theme`, the override hooks for the stable\n`.sv-grid-*` class names, and the anti-patterns (don't `@apply` inside\ngrid selectors, don't put utility classes on grid children, don't\nfight column widths in CSS).\n\n## Try it\n\nThere is no `density` prop - row height is the density control, and it is one\nnumber. Compact for a screen someone scans all day, comfortable for one they\nread.\n\n```svelte {runnable}\n<SvGrid data={people} {columns} rowHeight={28} showRowNumbers />\n\n<SvGrid data={people} {columns} rowHeight={48} showRowNumbers />\n```\n\n## Re-theming with tokens\n\nThe grid reads `--sg-*` custom properties, so a theme is a block of variables on\nany ancestor - no theme file, no build step.\n\n```svelte {runnable}\n<div class=\"midnight\">\n <SvGrid data={people} {columns} sortable />\n</div>\n\n<style>\n .midnight {\n --sg-bg: #0f172a;\n --sg-fg: #e2e8f0;\n --sg-border: #1e293b;\n --sg-header-bg: #111c33;\n --sg-header-fg: #93c5fd;\n --sg-accent: #38bdf8;\n --sg-row-hover: #16233d;\n }\n</style>\n```\n\n<!-- tutorial:learn-7-theme -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-7-theme\" data-docs-tutorial=\"learn-7-theme\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-7-theme.mp4\" poster=\"/tutorials/learn-7-theme.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 7: themes, dark mode and density, 113 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-7-theme.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-7-theme.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 7: themes, dark mode and density</strong> (1:53, silent). <a href=\"https://www.youtube.com/watch?v=yhJMpkQ0Kww\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>A grid that looks like a grid library is a grid that never ships. Theming here is CSS custom properties, not utility classes and not a config object.</p>\n<p>Twenty presets ship with the package, and importing one stylesheet is the whole setup. What a preset sets is a list of custom properties, which means you can set them yourself when one is close but not right. Same five people as before.</p>\n<p>A wrapper, and a handful of properties on it. Background, text, border, the header colours, the accent, the hover. They cascade like any other custom property, so this theme is scoped to this one grid rather than the page.</p>\n<p>The whole file, so nothing is hidden: the rows, the columns, a wrapper around the grid, and the properties on that wrapper.</p>\n<p>Here is that file running. Nine declarations, and nothing about the grid was configured to accept them.</p>\n<p>Density is separate from colour, and it is the setting people actually argue about. Row height is a prop, so the compact table and the comfortable one are the same component with one number changed.</p>\n<p>Dark mode is not a second stylesheet you maintain. Every preset declares a light palette and a dark one, and an attribute on the document element picks which is live. Every surface follows, including the parts you never styled.</p>\n<p>The density control here is the same row height prop behind a dropdown, which is what it usually becomes: a preference you save per user rather than a decision you make once for everybody.</p>\n<p>The theme builder on the site edits these tokens live and hands you the CSS at the end. Every property is listed on the page below. See you in lesson eight.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-7-theme -->\n"
3968
3976
  },
3969
3977
  {
3970
3978
  "slug": "getting-started/6-going-to-production",
@@ -3992,7 +4000,7 @@ export const docs = [
3992
4000
  "path": "docs/getting-started/sveltekit.md",
3993
4001
  "title": "SvGrid with SvelteKit",
3994
4002
  "section": "Getting started",
3995
- "markdown": "# SvGrid with SvelteKit\n\nA grid in a SvelteKit app is not the same job as a grid in a Vite SPA. The data\nusually comes from a `+page.server.ts` load, the page has to server-render for\ncrawlers, sorting should survive a refresh and a shared link, and edits go back\nthrough a form action. This page walks all four.\n\nEverything here was run against a real SvelteKit app before it was written -\n`sv create`, `sv add`, `svelte-check`, `vite build`, and the server HTML checked\nover HTTP.\n\n## Run it before you read it\n\nEverything this page builds is also a template, so you can have it on your\nmachine in three commands:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit\ncd people && npm install && npm run dev\n```\n\nThat is the app from [the end-to-end section](#the-whole-thing-end-to-end)\nbelow, plus a theme picker in the header - all 20 presets, light and dark,\nswitched live while the grid is running. Pick the starting theme up front if you\nprefer:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit --theme dracula --dark\n```\n\n`--theme` writes the preset into `src/app.css`, so the first paint and the\nserver-rendered HTML are already themed rather than flashing a frame of the\ndefault. Leave `--dark`/`--light` off and the app follows the visitor's OS\nsetting instead. `npm create @svgrid@latest -- --help` lists every preset id.\n\nThe rest of this page builds the same thing by hand, which is the better way to\nunderstand it.\n\n## 1. Scaffold\n\nSvGrid ships an add-on for the [Svelte CLI](https://svelte.dev/docs/cli), so a\nnew app is two commands:\n\n```bash\nnpx sv create myapp # pick SvelteKit, TypeScript\ncd myapp\nnpx sv add @svgrid\n```\n\n`sv add` resolves `@svgrid` to `@svgrid/sv`, adds `@svgrid/grid` to your\ndependencies, and - if you say yes to the demo - writes a working grid to\n`src/routes/svgrid-demo/+page.svelte`. To skip the prompts:\n\n```bash\nnpx sv add \"@svgrid=demo:yes+enterprise:no\" --no-download-check\n```\n\nTwo things worth knowing. `sv add` must run **inside** a project - in an empty\ndirectory it fails with *\"Invalid workspace\"*, which is the CLI's own guard, not\nthe add-on. And community add-ons are still marked experimental upstream, so if\n`sv` changes under you, `npm install @svgrid/grid` does the same job.\n\nAdding to an existing app is just the install:\n\n```bash\nnpm install @svgrid/grid\n```\n\n## 2. Load data on the server\n\nPut the query in `+page.server.ts` and hand the rows to the page. Nothing about\nSvGrid is special here - it takes a plain array.\n\n```ts\n// src/routes/people/+page.server.ts\nimport type { PageServerLoad } from './$types'\nimport { listPeople } from '$lib/db'\n\nexport const load: PageServerLoad = () => {\n return { rows: listPeople() }\n}\n```\n\n```svelte\n<!-- src/routes/people/+page.svelte -->\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/db'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name' },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n</script>\n\n<SvGrid data={data.rows} {columns} sortable containerHeight={320} />\n```\n\nType the column array against your row type. `GridColumns<Person>` checks every\n`field` against real keys; a bare `GridColumns` widens the row to\n`Record<string, unknown>` and stops checking them.\n\n## 3. What actually server-renders\n\nThe server HTML contains the header and a **viewport-sized window of rows** with\ntheir real cell values. That is what a crawler indexes and what a reader with JS\ndisabled sees.\n\nIt does *not* contain every row. Virtualization survives SSR, so a 5,000-row\ngrid does not serialise 5,000 rows into the page - the rest arrive when the\nclient measures the viewport and takes over. Hydration is clean because the\nserver and the first client render produce the same markup.\n\nYou can check it yourself on any page:\n\n```bash\nnpm run build && npm run preview\ncurl -s http://localhost:4173/people | grep -c 'data-svgrid-row'\n```\n\nA non-zero count means rows are in the HTML.\n\n> This genuinely did not work before `@svgrid/grid@2.6.8`. Both virtualizers\n> learned their row count from an `$effect`, and effects never run during SSR,\n> so the server emitted an empty `<tbody>`. If you are on an older version and\n> SEO matters, upgrade.\n\n## 4. Sorting that survives a refresh\n\nThe idiomatic SvelteKit move is to keep sort state in the URL. The link is then\nshareable and bookmarkable, the back button works, and the sorted page is\nserver-rendered - so it works with JS disabled too.\n\nSort on the server from the query string:\n\n```ts\n// src/routes/people/+page.server.ts\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n```\n\nThen tell the grid the server owns the ordering, and push header clicks into the\nURL:\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n</script>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n/>\n```\n\n`externalSort` is the important prop: the grid keeps rendering sort indicators\nand cycling on header click, but stops reordering rows itself - because the rows\narriving from `load` are already in the right order. Without it the grid sorts\nthe page a second time, which is wasted work and goes wrong the moment the\nserver is paginating.\n\n`keepFocus` and `noScroll` stop the navigation stealing focus from the header\nyou just clicked or jumping the page to the top.\n\n## 5. Saving an edit through a form action\n\nTurn on editing, then push each committed change at an action:\n\n```ts\n// +page.server.ts\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const form = await request.formData()\n renamePerson(Number(form.get('id')), String(form.get('name')))\n return { success: true }\n },\n}\n```\n\n```svelte\n<script lang=\"ts\">\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<SvGrid data={data.rows} {columns} editable {onCellValueChange} />\n```\n\nThe grid updates its own copy of the row immediately, so the cell shows the new\nvalue while the request is in flight. Handle a rejected save by reloading\n(`invalidateAll()`) or by writing the old value back.\n\nSvelteKit's CSRF protection rejects a cross-origin POST to an action. Browsers\nsend the `Origin` header themselves so this is invisible in an app - but if you\nscript the endpoint from Node or curl, set `Origin` or you will get\n`403 Cross-site POST form submissions are forbidden`.\n\n## The whole thing, end to end\n\nFour commands and three files. Every line below was run from a clean scaffold\nbefore it was published here: `svelte-check` reports 0 errors, the page\nserver-renders its rows, clicking a header rewrites the URL, and an edit\nsurvives a reload.\n\nIf you would rather run it than type it,\n`npm create @svgrid@latest people -- --template sveltekit` gives you this app\nwith a theme picker already wired in. The files below are what it contains.\n\n```bash\nnpx sv create people --template minimal --types ts\ncd people\nnpx sv add \"@svgrid=demo:no+enterprise:no\" --no-download-check\nnpm install\n```\n\n**`src/lib/people.ts`** - stands in for your database.\n\n```ts\nexport type Person = { id: number; name: string; role: string; year: number }\n\nconst people: Person[] = [\n { id: 1, name: 'Ada Lovelace', role: 'Mathematician', year: 1843 },\n { id: 2, name: 'Grace Hopper', role: 'Rear Admiral', year: 1952 },\n { id: 3, name: 'Karen Sparck Jones', role: 'Computer Scientist', year: 1972 },\n { id: 4, name: 'Barbara Liskov', role: 'Computer Scientist', year: 1968 },\n { id: 5, name: 'Margaret Hamilton', role: 'Software Engineer', year: 1969 },\n]\n\nexport function listPeople(sortBy: keyof Person = 'name', desc = false): Person[] {\n const rows = [...people]\n rows.sort((a, b) => (a[sortBy] > b[sortBy] ? 1 : a[sortBy] < b[sortBy] ? -1 : 0))\n return desc ? rows.reverse() : rows\n}\n\nexport function renamePerson(id: number, name: string): void {\n const row = people.find((p) => p.id === id)\n if (row) row.name = name\n}\n```\n\n**`src/routes/people/+page.server.ts`** - sorts from the query string, and takes\nthe edit.\n\n```ts\nimport type { Actions, PageServerLoad } from './$types'\nimport { listPeople, renamePerson, type Person } from '$lib/people'\n\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const data = await request.formData()\n renamePerson(Number(data.get('id')), String(data.get('name')))\n return { success: true }\n },\n}\n```\n\n**`src/routes/people/+page.svelte`**\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/people'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', editable: true },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n\n // Header click -> URL -> server sorts -> load returns ordered rows.\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n\n // Committed edit -> form action -> database.\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<h1>People</h1>\n<p>Click a header to sort - the order lives in the URL. Double-click a name to edit it.</p>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n editable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n {onCellValueChange}\n containerHeight={320}\n/>\n```\n\nThen:\n\n```bash\nnpm run dev # http://localhost:5173/people\n```\n\nThree things to try, in this order:\n\n1. **Click the `Year` header.** The URL becomes `?sort=year&dir=asc`. Copy that\n link into a new tab - it opens already sorted, because the server did it.\n2. **Double-click a name, change it, press Enter, then reload.** The edit went\n through the form action and survived.\n3. **View source, or `curl localhost:5173/people`.** The rows are in the HTML,\n not just injected by JS. That is what a crawler sees.\n\n## 6. Going further\n\n- **Server-side paging and filtering** for large tables: `externalFilter` and\n `externalPagination` are the equivalents of `externalSort` above. See\n [server paging](../help/server/server-paging.md),\n [server filtering](../help/server/server-filtering.md) and the\n [server row model](../help/server/server-row-model.md), which wraps the whole\n request shape rather than wiring each prop by hand.\n- **Owning the markup.** If you need a table only you could write, the headless\n engine is importable on its own from `@svgrid/grid/core` and runs anywhere,\n including inside a load function. See [Why headless?](../why-headless.md).\n- **Themes and dark mode.** Set the theme attribute before first paint from an\n inline script in `app.html`, or the page renders in the wrong palette for a\n frame. The `sveltekit` template above ships that script plus a runtime picker\n built on `resolveThemeTokens` from `@svgrid/grid/themes`, which is the short\n way to see the pattern. [Theme and density](./5-theme-and-density.md) covers\n it properly.\n- **Gating it behind a login.** The `sveltekit` template ships an auth scaffold:\n a cookie session, PBKDF2 password hashing over Web Crypto (so it still runs on\n an edge adapter), and one `PROTECTED` list in `hooks.server.ts` that gates routes\n before any load runs. The role check sits in the form action, not only in the\n UI - hiding an edit button stops nobody from posting the action by hand.\n- **Deployment.** SvGrid is a normal client dependency with no build step or\n server runtime of its own, so any SvelteKit adapter works unchanged.\n\n<!-- tutorial:learn-9-sveltekit -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-9-sveltekit\" data-docs-tutorial=\"learn-9-sveltekit\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-9-sveltekit.mp4\" poster=\"/tutorials/learn-9-sveltekit.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 9: SvelteKit end to end, 81 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-9-sveltekit.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-9-sveltekit.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 9: SvelteKit end to end</strong> (1:21, silent). <a href=\"https://www.youtube.com/watch?v=SQTeHpA7Bys\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>SvelteKit gives you a place to put the query that is already on the server. The grid does not need to know about any of it: it takes an array.</p>\n<p>There is a SvelteKit template, so the whole app is two commands. If you have a project already, installing the package is enough.</p>\n<p>The load function runs on the server. Your database credentials are already there, so the query belongs there too, and the browser never sees it.</p>\n<p>The page takes what that returned and hands it straight to the grid. Type the columns against your row type, as in lesson two, and the compiler checks the field names for you.</p>\n<p>Here is a grid over a real endpoint, which is what your page becomes once the load function points at a database instead of a demo API.</p>\n<p>The part worth knowing: the server HTML contains a viewport of real rows, not an empty container waiting for hydration. A crawler sees data, and a reader sees the table before the JavaScript arrives.</p>\n<p>URL-driven sorting and form actions for edits are on the page below. One lesson to go.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-9-sveltekit -->\n\n## See also\n\n- [Going to production](./6-going-to-production.md) - CSP, SSR, accessibility\n- [First grid](./2-first-grid.md) - the framework-agnostic walkthrough\n- [Why headless?](../why-headless.md) - when to skip the render component\n"
4003
+ "markdown": "# SvGrid with SvelteKit\n\nA grid in a SvelteKit app is not the same job as a grid in a Vite SPA. The data\nusually comes from a `+page.server.ts` load, the page has to server-render for\ncrawlers, sorting should survive a refresh and a shared link, and edits go back\nthrough a form action. This page walks all four.\n\nEverything here was run against a real SvelteKit app before it was written -\n`sv create`, `sv add`, `svelte-check`, `vite build`, and the server HTML checked\nover HTTP.\n\n## Run it before you read it\n\nEverything this page builds is also a template, so you can have it on your\nmachine in three commands:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit\ncd people && npm install && npm run dev\n```\n\nThat is the app from [the end-to-end section](#the-whole-thing-end-to-end)\nbelow, plus a theme picker in the header - all 20 presets, light and dark,\nswitched live while the grid is running. Pick the starting theme up front if you\nprefer:\n\n```bash\nnpm create @svgrid@latest people -- --template sveltekit --theme dracula --dark\n```\n\n`--theme` writes the preset into `src/app.css`, so the first paint and the\nserver-rendered HTML are already themed rather than flashing a frame of the\ndefault. Leave `--dark`/`--light` off and the app follows the visitor's OS\nsetting instead. `npm create @svgrid@latest -- --help` lists every preset id.\n\nThe rest of this page builds the same thing by hand, which is the better way to\nunderstand it.\n\n## 1. Scaffold\n\nSvGrid ships an add-on for the [Svelte CLI](https://svelte.dev/docs/cli), so a\nnew app is two commands:\n\n```bash\nnpx sv create myapp # pick SvelteKit, TypeScript\ncd myapp\nnpx sv add @svgrid\n```\n\n`sv add` resolves `@svgrid` to `@svgrid/sv`, adds `@svgrid/grid` to your\ndependencies, and - if you say yes to the demo - writes a working grid to\n`src/routes/svgrid-demo/+page.svelte`. It also offers to add a \"Tables and data grids\"\nsection to `AGENTS.md`, so a coding assistant asked for a table uses `<SvGrid>`\nrather than writing one. To skip the prompts:\n\n```bash\nnpx sv add \"@svgrid=demo:yes+agents:yes+enterprise:no\" --no-download-check\n```\n\nTwo things worth knowing. `sv add` must run **inside** a project - in an empty\ndirectory it fails with *\"Invalid workspace\"*, which is the CLI's own guard, not\nthe add-on. And community add-ons are still marked experimental upstream, so if\n`sv` changes under you, `npm install @svgrid/grid` does the same job.\n\nAdding to an existing app is just the install:\n\n```bash\nnpm install @svgrid/grid\n```\n\n## 2. Load data on the server\n\nPut the query in `+page.server.ts` and hand the rows to the page. Nothing about\nSvGrid is special here - it takes a plain array.\n\n```ts\n// src/routes/people/+page.server.ts\nimport type { PageServerLoad } from './$types'\nimport { listPeople } from '$lib/db'\n\nexport const load: PageServerLoad = () => {\n return { rows: listPeople() }\n}\n```\n\n```svelte\n<!-- src/routes/people/+page.svelte -->\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/db'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name' },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n</script>\n\n<SvGrid data={data.rows} {columns} sortable containerHeight={320} />\n```\n\nType the column array against your row type. `GridColumns<Person>` checks every\n`field` against real keys; a bare `GridColumns` widens the row to\n`Record<string, unknown>` and stops checking them.\n\n## 3. What actually server-renders\n\nThe server HTML contains the header and a **viewport-sized window of rows** with\ntheir real cell values. That is what a crawler indexes and what a reader with JS\ndisabled sees.\n\nIt does *not* contain every row. Virtualization survives SSR, so a 5,000-row\ngrid does not serialise 5,000 rows into the page - the rest arrive when the\nclient measures the viewport and takes over. Hydration is clean because the\nserver and the first client render produce the same markup.\n\nYou can check it yourself on any page:\n\n```bash\nnpm run build && npm run preview\ncurl -s http://localhost:4173/people | grep -c 'data-svgrid-row'\n```\n\nA non-zero count means rows are in the HTML.\n\n> This genuinely did not work before `@svgrid/grid@2.6.8`. Both virtualizers\n> learned their row count from an `$effect`, and effects never run during SSR,\n> so the server emitted an empty `<tbody>`. If you are on an older version and\n> SEO matters, upgrade.\n\n## 4. Sorting that survives a refresh\n\nThe idiomatic SvelteKit move is to keep sort state in the URL. The link is then\nshareable and bookmarkable, the back button works, and the sorted page is\nserver-rendered - so it works with JS disabled too.\n\nSort on the server from the query string:\n\n```ts\n// src/routes/people/+page.server.ts\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n```\n\nThen tell the grid the server owns the ordering, and push header clicks into the\nURL:\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n</script>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n/>\n```\n\n`externalSort` is the important prop: the grid keeps rendering sort indicators\nand cycling on header click, but stops reordering rows itself - because the rows\narriving from `load` are already in the right order. Without it the grid sorts\nthe page a second time, which is wasted work and goes wrong the moment the\nserver is paginating.\n\n`keepFocus` and `noScroll` stop the navigation stealing focus from the header\nyou just clicked or jumping the page to the top.\n\n## 5. Saving an edit through a form action\n\nTurn on editing, then push each committed change at an action:\n\n```ts\n// +page.server.ts\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const form = await request.formData()\n renamePerson(Number(form.get('id')), String(form.get('name')))\n return { success: true }\n },\n}\n```\n\n```svelte\n<script lang=\"ts\">\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<SvGrid data={data.rows} {columns} editable {onCellValueChange} />\n```\n\nThe grid updates its own copy of the row immediately, so the cell shows the new\nvalue while the request is in flight. Handle a rejected save by reloading\n(`invalidateAll()`) or by writing the old value back.\n\nSvelteKit's CSRF protection rejects a cross-origin POST to an action. Browsers\nsend the `Origin` header themselves so this is invisible in an app - but if you\nscript the endpoint from Node or curl, set `Origin` or you will get\n`403 Cross-site POST form submissions are forbidden`.\n\n## The whole thing, end to end\n\nFour commands and three files. Every line below was run from a clean scaffold\nbefore it was published here: `svelte-check` reports 0 errors, the page\nserver-renders its rows, clicking a header rewrites the URL, and an edit\nsurvives a reload.\n\nIf you would rather run it than type it,\n`npm create @svgrid@latest people -- --template sveltekit` gives you this app\nwith a theme picker already wired in. The files below are what it contains.\n\n```bash\nnpx sv create people --template minimal --types ts\ncd people\nnpx sv add \"@svgrid=demo:no+agents:yes+enterprise:no\" --no-download-check\nnpm install\n```\n\n**`src/lib/people.ts`** - stands in for your database.\n\n```ts\nexport type Person = { id: number; name: string; role: string; year: number }\n\nconst people: Person[] = [\n { id: 1, name: 'Ada Lovelace', role: 'Mathematician', year: 1843 },\n { id: 2, name: 'Grace Hopper', role: 'Rear Admiral', year: 1952 },\n { id: 3, name: 'Karen Sparck Jones', role: 'Computer Scientist', year: 1972 },\n { id: 4, name: 'Barbara Liskov', role: 'Computer Scientist', year: 1968 },\n { id: 5, name: 'Margaret Hamilton', role: 'Software Engineer', year: 1969 },\n]\n\nexport function listPeople(sortBy: keyof Person = 'name', desc = false): Person[] {\n const rows = [...people]\n rows.sort((a, b) => (a[sortBy] > b[sortBy] ? 1 : a[sortBy] < b[sortBy] ? -1 : 0))\n return desc ? rows.reverse() : rows\n}\n\nexport function renamePerson(id: number, name: string): void {\n const row = people.find((p) => p.id === id)\n if (row) row.name = name\n}\n```\n\n**`src/routes/people/+page.server.ts`** - sorts from the query string, and takes\nthe edit.\n\n```ts\nimport type { Actions, PageServerLoad } from './$types'\nimport { listPeople, renamePerson, type Person } from '$lib/people'\n\nexport const load: PageServerLoad = ({ url }) => {\n const sortBy = (url.searchParams.get('sort') ?? 'name') as keyof Person\n const desc = url.searchParams.get('dir') === 'desc'\n return { rows: listPeople(sortBy, desc), sortBy, desc }\n}\n\nexport const actions: Actions = {\n rename: async ({ request }) => {\n const data = await request.formData()\n renamePerson(Number(data.get('id')), String(data.get('name')))\n return { success: true }\n },\n}\n```\n\n**`src/routes/people/+page.svelte`**\n\n```svelte\n<script lang=\"ts\">\n import { goto } from '$app/navigation'\n import { page } from '$app/state'\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import type { Person } from '$lib/people'\n\n let { data } = $props()\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', editable: true },\n { field: 'role', header: 'Role' },\n { field: 'year', header: 'Year' },\n ]\n\n // Header click -> URL -> server sorts -> load returns ordered rows.\n function onSortingChange(sorting: Array<{ id: string; desc: boolean }>) {\n const next = new URL(page.url)\n if (sorting.length === 0) {\n next.searchParams.delete('sort')\n next.searchParams.delete('dir')\n } else {\n next.searchParams.set('sort', sorting[0]!.id)\n next.searchParams.set('dir', sorting[0]!.desc ? 'desc' : 'asc')\n }\n goto(next, { keepFocus: true, noScroll: true })\n }\n\n // Committed edit -> form action -> database.\n async function onCellValueChange(e: { row: Person; columnId: string; newValue: unknown }) {\n if (e.columnId !== 'name') return\n const body = new FormData()\n body.set('id', String(e.row.id))\n body.set('name', String(e.newValue))\n await fetch('?/rename', { method: 'POST', body })\n }\n</script>\n\n<h1>People</h1>\n<p>Click a header to sort - the order lives in the URL. Double-click a name to edit it.</p>\n\n<SvGrid\n data={data.rows}\n {columns}\n sortable\n editable\n externalSort\n initialSorting={[{ id: data.sortBy, desc: data.desc }]}\n {onSortingChange}\n {onCellValueChange}\n containerHeight={320}\n/>\n```\n\nThen:\n\n```bash\nnpm run dev # http://localhost:5173/people\n```\n\nThree things to try, in this order:\n\n1. **Click the `Year` header.** The URL becomes `?sort=year&dir=asc`. Copy that\n link into a new tab - it opens already sorted, because the server did it.\n2. **Double-click a name, change it, press Enter, then reload.** The edit went\n through the form action and survived.\n3. **View source, or `curl localhost:5173/people`.** The rows are in the HTML,\n not just injected by JS. That is what a crawler sees.\n\n## 6. Going further\n\n- **Server-side paging and filtering** for large tables: `externalFilter` and\n `externalPagination` are the equivalents of `externalSort` above. See\n [server paging](../help/server/server-paging.md),\n [server filtering](../help/server/server-filtering.md) and the\n [server row model](../help/server/server-row-model.md), which wraps the whole\n request shape rather than wiring each prop by hand.\n- **Owning the markup.** If you need a table only you could write, the headless\n engine is importable on its own from `@svgrid/grid/core` and runs anywhere,\n including inside a load function. See [Why headless?](../why-headless.md).\n- **Themes and dark mode.** Set the theme attribute before first paint from an\n inline script in `app.html`, or the page renders in the wrong palette for a\n frame. The `sveltekit` template above ships that script plus a runtime picker\n built on `resolveThemeTokens` from `@svgrid/grid/themes`, which is the short\n way to see the pattern. [Theme and density](./5-theme-and-density.md) covers\n it properly.\n- **Gating it behind a login.** The `sveltekit` template ships an auth scaffold:\n a cookie session, PBKDF2 password hashing over Web Crypto (so it still runs on\n an edge adapter), and one `PROTECTED` list in `hooks.server.ts` that gates routes\n before any load runs. The role check sits in the form action, not only in the\n UI - hiding an edit button stops nobody from posting the action by hand.\n- **Deployment.** SvGrid is a normal client dependency with no build step or\n server runtime of its own, so any SvelteKit adapter works unchanged.\n\n<!-- tutorial:learn-9-sveltekit -->\n<figure class=\"docs-tutorial\" id=\"tutorial-learn-9-sveltekit\" data-docs-tutorial=\"learn-9-sveltekit\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/learn-9-sveltekit.mp4\" poster=\"/tutorials/learn-9-sveltekit.poster.webp\" width=\"960\" height=\"540\" muted loop playsinline preload=\"none\" aria-label=\"Learn SvGrid 9: SvelteKit end to end, 81 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/learn-9-sveltekit.vtt\" default>Your browser does not play embedded video. <a href=\"/tutorials/learn-9-sveltekit.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>Learn SvGrid 9: SvelteKit end to end</strong> (1:21, silent). <a href=\"https://www.youtube.com/watch?v=SQTeHpA7Bys\" rel=\"noopener\">Watch with narration on YouTube</a></figcaption>\n<details class=\"docs-tutorial-transcript\"><summary>Transcript</summary>\n<p>SvelteKit gives you a place to put the query that is already on the server. The grid does not need to know about any of it: it takes an array.</p>\n<p>There is a SvelteKit template, so the whole app is two commands. If you have a project already, installing the package is enough.</p>\n<p>The load function runs on the server. Your database credentials are already there, so the query belongs there too, and the browser never sees it.</p>\n<p>The page takes what that returned and hands it straight to the grid. Type the columns against your row type, as in lesson two, and the compiler checks the field names for you.</p>\n<p>Here is a grid over a real endpoint, which is what your page becomes once the load function points at a database instead of a demo API.</p>\n<p>The part worth knowing: the server HTML contains a viewport of real rows, not an empty container waiting for hydration. A crawler sees data, and a reader sees the table before the JavaScript arrives.</p>\n<p>URL-driven sorting and form actions for edits are on the page below. One lesson to go.</p>\n</details>\n</figure>\n<!-- /tutorial:learn-9-sveltekit -->\n\n## See also\n\n- [Going to production](./6-going-to-production.md) - CSP, SSR, accessibility\n- [First grid](./2-first-grid.md) - the framework-agnostic walkthrough\n- [Why headless?](../why-headless.md) - when to skip the render component\n"
3996
4004
  },
3997
4005
  {
3998
4006
  "slug": "help/accessibility",
@@ -4342,14 +4350,14 @@ export const docs = [
4342
4350
  "path": "docs/help/columns/column-pinning.md",
4343
4351
  "title": "Column pinning",
4344
4352
  "section": "Columns",
4345
- "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![Three column regions: a pinned-left column and a pinned-right column stay fixed to the edges while the center columns scroll horizontally beneath them.](/docs-media/grid-column-pinning.svg)\n\nLive demo - pin Company left, Price right, scroll the middle:\n\n<div data-docs-demo=\"25-column-pinning\" data-height=\"480\"></div>\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\n`SvGridApi` exposes `setColumnPinning` and `getColumnPinning` for\nread / write from outside the grid:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n const data = people\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import type { SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n</script>\n\n<SvGrid {data} {columns} features={features}\n onApiReady={(next) => (api = next)} />\n\n<button onclick={() => api?.setColumnPinning({ left: ['company'], right: ['actions'] })}>\n Pin company left, actions right\n</button>\n<button onclick={() => api?.setColumnPinning({ left: [], right: [] })}>\n Unpin all\n</button>\n```\n\nFor initial pinning at mount, use the `initialColumnPinning` prop on\n`<SvGrid>`:\n\n```svelte\n<SvGrid {data} {columns} features={features}\n initialColumnPinning={{ left: ['company'], right: ['actions'] }} />\n```\n\n`getColumnPinning()` returns `{ left: string[]; right: string[] }` -\nthe array order is the visible order along the pinned edge.\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## Styling\n\nPinned columns are visually differentiated from the scrollable middle\nthrough three layered cues:\n\n1. **A distinct background tint** so the pinned strip reads as \"frozen\"\n even before you scroll.\n2. **A 1-pixel divider** on the inside edge (the side facing the\n scrollable region) and a soft drop shadow that fades into the\n scroll area.\n3. **A bolder header** font weight so the frozen header reads as part\n of the grid chrome.\n\nAll three cues are driven by CSS custom properties. Override them to\nmatch your design system:\n\n| Token | Default | Used for |\n|-----------------------------|---------------------------------------------------------------------------|--------------------------------|\n| `--sg-pinned-bg` | `color-mix(in oklab, var(--sg-header-bg) 92%, var(--sg-accent) 8%)` | Body cells in pinned columns |\n| `--sg-pinned-header-bg` | `color-mix(in oklab, var(--sg-header-bg) 86%, var(--sg-accent) 14%)` | Header cells in pinned columns |\n| `--sg-pinned-divider` | `var(--sg-border)` | The 1-pixel inside-edge line |\n| `--sg-pinned-shadow-color` | `rgba(15, 23, 42, 0.22)` | The drop shadow into the scroll area |\n\n> **Keep the pinned background opaque.** When you override `--sg-pinned-bg`\n> with `color-mix`, the two percentages **must sum to 100%**. If they add up\n> to less (e.g. `60% + 20% = 80%`), CSS scales the result's alpha down to\n> `0.8` - the pinned column turns semi-transparent and the scrolling middle\n> columns bleed through it. Always pair the percentages as `N%` / `(100 - N)%`.\n\nThe fallbacks compute a subtle accent-tinted background from your\nexisting header background, so a pinned column never looks identical\nto the rest of the grid even if you don't set anything. That works\nwell for a low-chroma accent. If yours is saturated, the derived tint\ncan get loud enough to compete with your selection colour - name a\nneutral fill instead and let the divider and shadow carry the\nboundary:\n\n```css\n.themed-host {\n --sg-pinned-bg: var(--sg-row-alt-bg);\n --sg-pinned-header-bg: var(--sg-header-bg);\n --sg-pinned-divider: var(--sg-border);\n --sg-pinned-shadow-color: rgba(94, 72, 52, 0.18);\n}\n```\n\nTo opt out of the tint and match the body exactly:\n\n```css\n.themed-host {\n --sg-pinned-bg: var(--sg-bg);\n --sg-pinned-header-bg: var(--sg-header-bg);\n}\n```\n\nOr to make pinned columns *very* obvious - useful for high-density\nfinancial grids where the user must instantly know which side is\nfrozen:\n\n```css\n.themed-host {\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg) 80%, var(--sg-accent) 20%);\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-bg) 70%, var(--sg-accent) 30%);\n --sg-pinned-divider: var(--sg-accent);\n}\n```\n\n### Hover, selection, and zebra rows\n\nThe pinned tint sits *under* the hover, selection, and zebra row\nbackgrounds via stacked `linear-gradient` paints. That means:\n\n- Hovering a row in a pinned cell shows the pinned tint *with* the\n hover overlay on top - the user still sees the row highlight.\n- Selecting a row layers the selection colour over the pinned tint.\n- Zebra rows re-paint the pinned cell so the alternating row\n background doesn't leak through the pin.\n\nYou don't need to override anything for these states to work; they\nfollow `--sg-row-hover-bg`, `--sg-selection-bg`, and the pinned tokens\nautomatically.\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## Try it\n\nPinning is a grid-level prop naming column ids, not a flag on the column.\nScroll the grid sideways: Name stays at the left edge, Salary at the right.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n initialColumnPinning={{ left: ['name'], right: ['salary'] }}\n/>\n```\n\n## Pinning both edges\n\nPinned columns stay put while the middle scrolls. Narrow the browser and the\ntwo pinned columns hold their edges while Department and City pan between them.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n initialColumnPinning={{ left: ['name'], right: ['salary'] }}\n sortable\n/>\n```\n\n## See also\n\n- [Column state](./column-state.md)\n- [`SvGridApi` reference](../../reference/SvGridApi.md) - `setColumnPinning` / `getColumnPinning` signatures.\n"
4353
+ "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![Three column regions: a pinned-left column and a pinned-right column stay fixed to the edges while the center columns scroll horizontally beneath them.](/docs-media/grid-column-pinning.svg)\n\nLive demo - pin Company left, Price right, scroll the middle:\n\n<div data-docs-demo=\"25-column-pinning\" data-height=\"480\"></div>\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\n`SvGridApi` exposes `setColumnPinning` and `getColumnPinning` for\nread / write from outside the grid:\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n const data = people\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```svelte\n<script lang=\"ts\">\n import type { SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n</script>\n\n<SvGrid {data} {columns} features={features}\n onApiReady={(next) => (api = next)} />\n\n<button onclick={() => api?.setColumnPinning({ left: ['company'], right: ['actions'] })}>\n Pin company left, actions right\n</button>\n<button onclick={() => api?.setColumnPinning({ left: [], right: [] })}>\n Unpin all\n</button>\n```\n\nFor initial pinning at mount, use the `initialColumnPinning` prop on\n`<SvGrid>`:\n\n```svelte\n<SvGrid {data} {columns} features={features}\n initialColumnPinning={{ left: ['company'], right: ['actions'] }} />\n```\n\n`getColumnPinning()` returns `{ left: string[]; right: string[] }` -\nthe array order is the visible order along the pinned edge.\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## Styling\n\nPinned columns are visually differentiated from the scrollable middle\nthrough three layered cues:\n\n1. **A distinct background tint** so the pinned strip reads as \"frozen\"\n even before you scroll.\n2. **A 1-pixel divider** on the inside edge (the side facing the\n scrollable region) and a soft drop shadow that fades into the\n scroll area.\n3. **A bolder header** font weight so the frozen header reads as part\n of the grid chrome.\n\nAll three cues are driven by CSS custom properties. Override them to\nmatch your design system:\n\n| Token | Default | Used for |\n|-----------------------------|---------------------------------------------------------------------------|--------------------------------|\n| `--sg-pinned-bg` | `color-mix(in oklab, var(--sg-header-bg) 92%, var(--sg-accent) 8%)` | Body cells in pinned columns |\n| `--sg-pinned-header-bg` | `color-mix(in oklab, var(--sg-header-bg) 86%, var(--sg-accent) 14%)` | Header cells in pinned columns |\n| `--sg-pinned-divider` | `var(--sg-border)` | The 1-pixel inside-edge line |\n| `--sg-pinned-shadow-color` | `rgba(15, 23, 42, 0.22)` | The drop shadow into the scroll area |\n\n> **Keep the pinned background opaque.** When you override `--sg-pinned-bg`\n> with `color-mix`, the two percentages **must sum to 100%**. If they add up\n> to less (e.g. `60% + 20% = 80%`), CSS scales the result's alpha down to\n> `0.8` - the pinned column turns semi-transparent and the scrolling middle\n> columns bleed through it. Always pair the percentages as `N%` / `(100 - N)%`.\n\nThe fallbacks compute a subtle accent-tinted background from your\nexisting header background, so a pinned column never looks identical\nto the rest of the grid even if you don't set anything. That works\nwell for a low-chroma accent. If yours is saturated, the derived tint\ncan get loud enough to compete with your selection colour - name a\nneutral fill instead and let the divider and shadow carry the\nboundary:\n\n```css\n.themed-host {\n --sg-pinned-bg: var(--sg-row-alt-bg);\n --sg-pinned-header-bg: var(--sg-header-bg);\n --sg-pinned-divider: var(--sg-border);\n --sg-pinned-shadow-color: rgba(94, 72, 52, 0.18);\n}\n```\n\nTo opt out of the tint and match the body exactly:\n\n```css\n.themed-host {\n --sg-pinned-bg: var(--sg-bg);\n --sg-pinned-header-bg: var(--sg-header-bg);\n}\n```\n\nOr to make pinned columns *very* obvious - useful for high-density\nfinancial grids where the user must instantly know which side is\nfrozen:\n\n```css\n.themed-host {\n --sg-pinned-bg: color-mix(in oklab, var(--sg-bg) 80%, var(--sg-accent) 20%);\n --sg-pinned-header-bg: color-mix(in oklab, var(--sg-bg) 70%, var(--sg-accent) 30%);\n --sg-pinned-divider: var(--sg-accent);\n}\n```\n\n### Hover, selection, and zebra rows\n\nThe pinned tint sits *under* the hover, selection, and zebra row\nbackgrounds via stacked `linear-gradient` paints. That means:\n\n- Hovering a row in a pinned cell shows the pinned tint *with* the\n hover overlay on top - the user still sees the row highlight.\n- Selecting a row layers the selection colour over the pinned tint.\n- Zebra rows re-paint the pinned cell so the alternating row\n background doesn't leak through the pin.\n\nYou don't need to override anything for these states to work; they\nfollow `--sg-row-hover-bg`, `--sg-selection-bg`, and the pinned tokens\nautomatically.\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. The pinned columns\n are always rendered, as their own runs either side of the virtual window;\n the columns between them stay virtualized, so a wide grid with pinned\n edges renders only the columns in view plus the pinned ones.\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## Try it\n\nPinning is a grid-level prop naming column ids, not a flag on the column.\nScroll the grid sideways: Name stays at the left edge, Salary at the right.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n initialColumnPinning={{ left: ['name'], right: ['salary'] }}\n/>\n```\n\n## Pinning both edges\n\nPinned columns stay put while the middle scrolls. Narrow the browser and the\ntwo pinned columns hold their edges while Department and City pan between them.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n initialColumnPinning={{ left: ['name'], right: ['salary'] }}\n sortable\n/>\n```\n\n## See also\n\n- [Column state](./column-state.md)\n- [`SvGridApi` reference](../../reference/SvGridApi.md) - `setColumnPinning` / `getColumnPinning` signatures.\n"
4346
4354
  },
4347
4355
  {
4348
4356
  "slug": "help/columns/column-sizing",
4349
4357
  "path": "docs/help/columns/column-sizing.md",
4350
4358
  "title": "Column sizing",
4351
4359
  "section": "Columns",
4352
- "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<div data-docs-demo=\"63-column-layout-api\" data-height=\"540\"></div>\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n const data = people\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nconst columns: GridColumns<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 {runnable}\n<SvGrid {columns} {data} features={{}} columnWidth={140} />\n```\n\n## User resizing\n\nOff by default. Set `columnResize` and every header grows a drag handle on its\nright edge: drag to widen or narrow, minimum 40 px, or focus the handle and use\nLeft/Right (Shift for 1px steps). Widths are stored per column id inside the\ngrid, so a resize survives sorting and filtering.\n\nLeaving it off is the reason `width` means what it says - the width you asked\nfor, not a starting point a user can drag away:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; city: string }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', city: 'London' },\n { id: 2, name: 'Grace Hopper', city: 'New York' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n { field: 'city', header: 'City', width: 150 },\n ]\n</script>\n\n<p>Fixed widths (the default):</p>\n<SvGrid data={people} {columns} containerHeight={140} />\n\n<p>With <code>columnResize</code> - drag a header edge:</p>\n<SvGrid data={people} {columns} containerHeight={140} columnResize />\n```\n\n### A drag is one undo\n\nA width the user drags, or double-clicks into, is one step in the grid's\nundo history: Ctrl+Z puts it back, Ctrl+Y widens it again. `onColumnResize`\nreports each such change with the column id and the new width.\n`api.setColumnWidth` and `api.autosizeColumn` record nothing and fire\nnothing, so restoring saved widths does not fill the history.\n\n### Locking one column: `resizable: false`\n\n`columnResize` turns resizing on for the grid; a column opts back out with\n`resizable: false`. Use it where the width is part of the layout rather than a\npreference - a row-number gutter, a checkbox column, a fixed icon column.\n\nOpting out removes that column's handle entirely, so the drag, the arrow keys\nand double-click-to-autosize all go with it, and the column menu drops its\nAutosize item. Nothing is left that lets a user change the width by accident:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; city: string }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', city: 'London' },\n { id: 2, name: 'Grace Hopper', city: 'New York' },\n ]\n\n const columns: GridColumns<Person> = [\n // Fixed: the row id is 60px because that is what fits, not a preference.\n { field: 'id', header: '#', width: 60, resizable: false },\n { field: 'name', header: 'Name', width: 200 },\n { field: 'city', header: 'City', width: 160 },\n ]\n</script>\n\n<SvGrid data={people} {columns} columnResize containerHeight={150} />\n```\n\nThe column option only ever subtracts. `resizable: true` on a grid without\n`columnResize` does nothing - the grid-wide prop is still the switch.\n\nProgrammatic sizing is unaffected either way: `api.setColumnWidth()`,\n`api.autosizeColumn()` and `fitColumns` all still apply to a locked column,\nexactly as they do when `columnResize` is off.\n\nThe handles come from an action the grid loads on demand, so a grid that leaves\n`columnResize` off never downloads that code.\n\n## Programmatic resizing + persistence\n\nThe imperative API exposes `setColumnWidth` and `getColumnWidths`:\n\n```svelte\n<script lang=\"ts\">\n import type { SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n\n function save() {\n if (!api) return\n localStorage.setItem('widths', JSON.stringify(api.getColumnWidths()))\n }\n function restore() {\n if (!api) return\n const saved: Record<string, number> = JSON.parse(localStorage.getItem('widths') ?? '{}')\n for (const [id, w] of Object.entries(saved)) api.setColumnWidth(id, w)\n }\n</script>\n\n<SvGrid {data} {columns} features={features}\n onApiReady={(next) => (api = next)} />\n\n<button onclick={save}>Save layout</button>\n<button onclick={restore}>Restore layout</button>\n```\n\n`getColumnWidths()` returns every column's *current effective* width\n(user resize OR columnDef `width` OR grid-wide default), so the\nsnapshot round-trips cleanly.\n\n## Auto-fit\n\n`fitColumns={true}` scales every column proportionally to fill the\nviewport - the wrapper handles rounding-residue absorption + a modest\nshrink-to-fit (down to ~85 % of natural widths). For \"fit to content\"\n(longest cell text), pre-compute per-column widths once after a data\nload and call `api.setColumnWidth(id, w)` per column.\n\n## Column virtualization\n\nWith many columns, enable column virtualization so only the visible columns\nrender:\n\n```svelte {runnable}\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## Collapsing a column to nothing\n\n`api.setColumnCollapsed(id, true)` folds a column away the way a spreadsheet\nhides one: the column keeps its place, its cells and the width it had, and\ntakes no room until `setColumnCollapsed(id, false)` unfolds it. Its\nneighbours' borders meet at the seam. Arrow keys, Tab and Enter step over a\ncollapsed column, so the cursor never rests on it; `api.setActiveCell` and a\nsheet's Name Box still can. `api.isColumnCollapsed(id)` reads the state.\n\nThis is not `setColumnVisible(id, false)`, which takes the column out of the\nmodel: every column after a hidden one moves up an index, which is right for\na \"Choose columns\" panel and wrong for anything that addresses cells by\nposition, such as a sheet's formulas and formats. Collapse when the indices\nmust hold; hide when the column should be gone.\n\n```ts\napi.setColumnCollapsed('cost', true) // folded: cells and width kept, no room\napi.isColumnCollapsed('cost') // true\napi.setColumnCollapsed('cost', false) // back at the width it had\n```\n\nThe spreadsheet shell's Hide and Unhide (Ctrl+9, Ctrl+0, and the header\nmenus) are this, kept per sheet.\n\n## Gotchas\n\n- Under `columnResize`, a `width` in the column def is an **initial** width:\n once the user drags, their override wins for the rest of the session. Without\n the prop it is simply the width.\n- To persist what the user chose, read `api.getColumnWidths()` and replay it\n with `api.setColumnWidth(id, w)` - see [Programmatic resizing](#programmatic-resizing--persistence)\n above. `api.getState()` carries `columnWidths` too, if you are already\n saving a whole view.\n\n## Try it\n\nWithout `fitColumns` the explicit widths are used as-is and any leftover space\nstays empty on the right. With it, the columns scale to fill. Both carry\n`columnResize`, so drag a header edge in either - a user resize wins from then\non, including over `fitColumns`.\n\n```svelte {runnable}\n<SvGrid data={people} {columns} columnResize />\n\n<SvGrid data={people} {columns} columnResize fitColumns />\n```\n\n## See also\n\n- [Column moving](./column-moving.md)\n- [Column pinning](./column-pinning.md)\n"
4360
+ "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<div data-docs-demo=\"63-column-layout-api\" data-height=\"540\"></div>\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n let rows = $state<Person[]>(people)\n const data = people\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 200 },\n { field: 'department', header: 'Department', width: 150 },\n { field: 'city', header: 'City', width: 140 },\n { field: 'age', header: 'Age', width: 90 },\n { field: 'salary', header: 'Salary', width: 130, format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\n\n```ts\nconst columns: GridColumns<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 {runnable}\n<SvGrid {columns} {data} features={{}} columnWidth={140} />\n```\n\n## User resizing\n\nOff by default. Set `columnResize` and every header grows a drag handle on its\nright edge: drag to widen or narrow, minimum 40 px, or focus the handle and use\nLeft/Right (Shift for 1px steps). Widths are stored per column id inside the\ngrid, so a resize survives sorting and filtering.\n\nLeaving it off is the reason `width` means what it says - the width you asked\nfor, not a starting point a user can drag away:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; city: string }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', city: 'London' },\n { id: 2, name: 'Grace Hopper', city: 'New York' },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 180 },\n { field: 'city', header: 'City', width: 150 },\n ]\n</script>\n\n<p>Fixed widths (the default):</p>\n<SvGrid data={people} {columns} containerHeight={140} />\n\n<p>With <code>columnResize</code> - drag a header edge:</p>\n<SvGrid data={people} {columns} containerHeight={140} columnResize />\n```\n\n### A drag is one undo\n\nA width the user drags, or double-clicks into, is one step in the grid's\nundo history: Ctrl+Z puts it back, Ctrl+Y widens it again. `onColumnResize`\nreports each such change with the column id and the new width.\n`api.setColumnWidth` and `api.autosizeColumn` record nothing and fire\nnothing, so restoring saved widths does not fill the history.\n\n### Locking one column: `resizable: false`\n\n`columnResize` turns resizing on for the grid; a column opts back out with\n`resizable: false`. Use it where the width is part of the layout rather than a\npreference - a row-number gutter, a checkbox column, a fixed icon column.\n\nOpting out removes that column's handle entirely, so the drag, the arrow keys\nand double-click-to-autosize all go with it, and the column menu drops its\nAutosize item. Nothing is left that lets a user change the width by accident:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Person = { id: number; name: string; city: string }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', city: 'London' },\n { id: 2, name: 'Grace Hopper', city: 'New York' },\n ]\n\n const columns: GridColumns<Person> = [\n // Fixed: the row id is 60px because that is what fits, not a preference.\n { field: 'id', header: '#', width: 60, resizable: false },\n { field: 'name', header: 'Name', width: 200 },\n { field: 'city', header: 'City', width: 160 },\n ]\n</script>\n\n<SvGrid data={people} {columns} columnResize containerHeight={150} />\n```\n\nThe column option only ever subtracts. `resizable: true` on a grid without\n`columnResize` does nothing - the grid-wide prop is still the switch.\n\nProgrammatic sizing is unaffected either way: `api.setColumnWidth()`,\n`api.autosizeColumn()` and `fitColumns` all still apply to a locked column,\nexactly as they do when `columnResize` is off.\n\nThe handles come from an action the grid loads on demand, so a grid that leaves\n`columnResize` off never downloads that code.\n\n## Programmatic resizing + persistence\n\nThe imperative API exposes `setColumnWidth` and `getColumnWidths`:\n\n```svelte\n<script lang=\"ts\">\n import type { SvGridApi } from '@svgrid/grid'\n let api = $state<SvGridApi<typeof features, Person> | null>(null)\n\n function save() {\n if (!api) return\n localStorage.setItem('widths', JSON.stringify(api.getColumnWidths()))\n }\n function restore() {\n if (!api) return\n const saved: Record<string, number> = JSON.parse(localStorage.getItem('widths') ?? '{}')\n for (const [id, w] of Object.entries(saved)) api.setColumnWidth(id, w)\n }\n</script>\n\n<SvGrid {data} {columns} features={features}\n onApiReady={(next) => (api = next)} />\n\n<button onclick={save}>Save layout</button>\n<button onclick={restore}>Restore layout</button>\n```\n\n`getColumnWidths()` returns every column's *current effective* width\n(user resize OR columnDef `width` OR grid-wide default), so the\nsnapshot round-trips cleanly.\n\n## Auto-fit\n\n`fitColumns={true}` scales every column proportionally to fill the\nviewport - the wrapper handles rounding-residue absorption + a modest\nshrink-to-fit (down to ~85 % of natural widths). For \"fit to content\"\n(longest cell text), pre-compute per-column widths once after a data\nload and call `api.setColumnWidth(id, w)` per column.\n\n## Column virtualization\n\nWith many columns, enable column virtualization so only the visible columns\nrender:\n\n```svelte {runnable}\n<SvGrid\n {columns}\n {data}\n features={{}}\n columnVirtualization={true}\n columnWidth={120}\n columnOverscan={3}\n/>\n```\n\n`columnOverscan` is how many columns stay rendered ahead of a horizontal\nscroll at all times (default 3), with one kept behind it. The rendered\ncolumns don't move on every column boundary: they stay put while that\nmany are left ahead, then move three at once. Each move updates every\nrendered row, whether one column comes in or four, so fewer, larger\nmoves are less work, and the scroll frames in between change nothing.\nRight after a move, up to `columnOverscan + 2`\ncolumns are rendered ahead. Raise it if fast scrolling shows blank columns\nbefore they fill in; lower it for less work per scroll.\n\nSee [examples/src/demos/06-large-dataset.svelte](../../../examples/src/demos/06-large-dataset.svelte)\nfor a 100-column virtualized grid.\n\n## Collapsing a column to nothing\n\n`api.setColumnCollapsed(id, true)` folds a column away the way a spreadsheet\nhides one: the column keeps its place, its cells and the width it had, and\ntakes no room until `setColumnCollapsed(id, false)` unfolds it. Its\nneighbours' borders meet at the seam. Arrow keys, Tab and Enter step over a\ncollapsed column, so the cursor never rests on it; `api.setActiveCell` and a\nsheet's Name Box still can. `api.isColumnCollapsed(id)` reads the state.\n\nThis is not `setColumnVisible(id, false)`, which takes the column out of the\nmodel: every column after a hidden one moves up an index, which is right for\na \"Choose columns\" panel and wrong for anything that addresses cells by\nposition, such as a sheet's formulas and formats. Collapse when the indices\nmust hold; hide when the column should be gone.\n\n```ts\napi.setColumnCollapsed('cost', true) // folded: cells and width kept, no room\napi.isColumnCollapsed('cost') // true\napi.setColumnCollapsed('cost', false) // back at the width it had\n```\n\nThe spreadsheet shell's Hide and Unhide (Ctrl+9, Ctrl+0, and the header\nmenus) are this, kept per sheet.\n\n## Gotchas\n\n- Under `columnResize`, a `width` in the column def is an **initial** width:\n once the user drags, their override wins for the rest of the session. Without\n the prop it is simply the width.\n- To persist what the user chose, read `api.getColumnWidths()` and replay it\n with `api.setColumnWidth(id, w)` - see [Programmatic resizing](#programmatic-resizing--persistence)\n above. `api.getState()` carries `columnWidths` too, if you are already\n saving a whole view.\n\n## Try it\n\nWithout `fitColumns` the explicit widths are used as-is and any leftover space\nstays empty on the right. With it, the columns scale to fill. Both carry\n`columnResize`, so drag a header edge in either - a user resize wins from then\non, including over `fitColumns`.\n\n```svelte {runnable}\n<SvGrid data={people} {columns} columnResize />\n\n<SvGrid data={people} {columns} columnResize fitColumns />\n```\n\n## See also\n\n- [Column moving](./column-moving.md)\n- [Column pinning](./column-pinning.md)\n"
4353
4361
  },
4354
4362
  {
4355
4363
  "slug": "help/columns/column-spanning",
@@ -4505,6 +4513,13 @@ export const docs = [
4505
4513
  "section": "Help",
4506
4514
  "markdown": "# Expression query language - Enterprise\n\nThe expression language is a small, dependency-free query language your users\nauthor through the UI. It is the connective tissue behind [alerts](./alerts.md)\n(and, in time, styled and calculated columns): one model for predicates, scalar\nmaths, and change detection, evaluated the same way everywhere.\n\nIt ships in `@svgrid/enterprise`. Leaf comparisons delegate to the grid's own\n`applyExcelFilter`, so an expression's operators mean exactly what the column\nfilter menu means.\n\n## The editor\n\n`<SvExpressionEditor>` offers two modes over the same value:\n\n- **Builder** (default) - a list of conditions (column + operator + value)\n combined with *all* (AND) or *any* (OR), just like the filter row. Set\n operators show a token input; `between` shows two fields; blank operators show\n none.\n- **Text** - a free-text box for power users, with live validation and column\n maths.\n\nBoth show a live \"matches N of M\" count against a sample `rows` array.\n\n```svelte\n<script lang=\"ts\">\n import { SvExpressionEditor, type ExprColumn, type PredicateExpr } from '@svgrid/enterprise'\n\n const columns: ExprColumn[] = [\n { id: 'price', name: 'Price', type: 'number' },\n { id: 'region', name: 'Region', type: 'text' },\n ]\n let expr = $state<PredicateExpr>({ kind: 'const', value: true })\n</script>\n\n<SvExpressionEditor {columns} bind:value={expr} rows={sample} />\n```\n\n## Text syntax\n\nColumn references are a bare id (`price`) or a bracketed label (`[Unit Price]`)\nwhen the name has spaces.\n\n```\nprice > 100 AND region IN (\"EU\", \"US\")\nname CONTAINS \"widget\" OR name STARTSWITH \"gadget\"\nprice BETWEEN 10 AND 20\nname ISBLANK\nprice / qty >= 40 -- cross-column maths\nSUM(amount) > 10000 -- aggregate over the rows in scope\n```\n\n- Logic: `AND`, `OR`, `NOT`, parentheses.\n- Text/set/range operators: `CONTAINS`, `STARTSWITH`, `ENDSWITH`, `MATCHES`\n (regex), `IN (…)`, `BETWEEN … AND …`, `ISBLANK`, `ISNOTBLANK`.\n- Symbolic comparators: `=`, `!=`, `>`, `<`, `>=`, `<=` (these also drive\n cross-column maths).\n- Arithmetic: `+`, `-`, `*`, `/`, `%`, unary `-`.\n- Aggregates: `SUM(col)`, `AVG(col)`, `COUNT(col)`.\n- Functions: `ABS`, `ROUND`, `FLOOR`, `CEIL`, `MIN`, `MAX`, `IF`, `COALESCE`,\n `CONCAT`, `LOWER`, `UPPER`, `LEN`.\n\nA `column = literal` comparison parses to a grid-filter `cmp` node (so it folds\naccents/case like the filter row); general comparisons and maths parse to a\n`scalarCmp`.\n\n## The model\n\nThe canonical form is a JSON AST - no parser is needed at runtime. Three\nfamilies:\n\n- **`PredicateExpr`** - boolean: `and` / `or` / `not` / `cmp` (a filter-operator\n test) / `scalarCmp` (compare two scalar expressions) / `const`.\n- **`ScalarExpr`** - a single value: `col` / `lit` / `bin` (arithmetic) / `agg` /\n `func`.\n- **`ChangeExpr`** - `changed` / `delta` / `percentChange` / `crossed`, for the\n \"relative change\" alert trigger.\n\n## Evaluating expressions\n\n```ts\nimport { evaluatePredicate, parsePredicate, validateExpression } from '@svgrid/enterprise'\n\nconst expr = parsePredicate('price > 100 AND region = \"EU\"', columns)\nvalidateExpression(expr, columns) // [] when sound\nevaluatePredicate(expr, { row: { price: 120, region: 'EU' } }) // true\n```\n\n`evaluateScalar` and `evaluateChange` cover the other two families. All three are\npure functions - safe to unit-test and to run in a Web Worker.\n\nAdvanced scalar functions can be injected per evaluation via the context's\n`functions` map (for example, to delegate a formula to HyperFormula) without\nmaking it a hard dependency.\n\n## Try it\n\nBuild a predicate in the visual editor and watch the AST underneath. The same\nvalue round-trips through the text mode, which is what makes it storable.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvExpressionEditor } from '@svgrid/enterprise'\n import type { ExprColumn, PredicateExpr } from '@svgrid/enterprise'\n\n const columns: ExprColumn[] = [\n { id: 'name', name: 'Name', type: 'text' },\n { id: 'department', name: 'Department', type: 'text' },\n { id: 'age', name: 'Age', type: 'number' },\n { id: 'salary', name: 'Salary', type: 'number' },\n ]\n\n const rows = [\n { name: 'Ada Lovelace', department: 'Engineering', age: 36, salary: 142000 },\n { name: 'Grace Hopper', department: 'Engineering', age: 45, salary: 168000 },\n { name: 'Linus Torvalds', department: 'Platform', age: 54, salary: 155000 },\n ]\n\n let expr = $state<PredicateExpr>({ kind: 'const', value: true })\n</script>\n\n<SvExpressionEditor {columns} {rows} bind:value={expr} />\n\n<pre>{JSON.stringify(expr, null, 2)}</pre>\n```\n\nThe AST is plain JSON, so a rule authored here is something you can put in a\ndatabase and evaluate later - which is exactly what [alerts](./alerts.md) do\nwith it.\n\n## See also\n\n- [Alerts](./alerts.md) - the flagship consumer of this language.\n- [Filtering](./filtering/overview.md) - the column filter operators the `cmp` nodes reuse.\n"
4507
4515
  },
4516
+ {
4517
+ "slug": "help/figma",
4518
+ "path": "docs/help/figma.md",
4519
+ "title": "Figma design kit",
4520
+ "section": "Help",
4521
+ "markdown": "# Figma design kit\n\nSvGrid's design kit is on Figma Community:\n[SvGrid Design Kit](https://www.figma.com/community/file/1689644521333526144).\nDuplicate it into your drafts to design screens with the grid and the UI\ncomponents, then hand them to developers with the same names the code uses.\n\nThe kit is generated from this repository (`tools/figma`), so its colors,\nsizes and states are the ones the components render. Theme colors come from\nthe same `resolveThemeTokens()` function that writes\n`@svgrid/grid/themes/*.css`.\n\n## What is in the file\n\n- **Overview.** The palettes of each theme, every color and number variable\n per mode, the text styles and the shadows.\n- **Grid.** A complete orders screen, the example grid in every theme and\n density, then each part of the grid with its states: header cells and\n column groups, body cells (hover, selected, active, editing, invalid,\n range), group, tree, pinned and total rows, the pager, toolbar, status bar,\n find bar, tool panel, the column, filter, context and operator menus, and\n the loading and empty states.\n- **Components.** 37 UI components from `@svgrid/grid` in seven groups:\n actions, inputs, selection, navigation, data display, feedback and\n overlays.\n\nEvery component sits on a card with its Svelte usage, the props that change\nits look, the source file it was measured from, and its variants in light and\ndark.\n\n## Variables are the CSS tokens\n\nEach color in the kit is a variable named after its `--sg-*` custom\nproperty, and Dev Mode shows the property as the variable's code. A selected\nrow in a design reads `var(--sg-selection-bg)`, which is what the developer\nsets or leaves to the theme.\n\n| Figma variable | In code |\n|---|---|\n| `surface/bg` | `--sg-bg` |\n| `text/fg`, `text/muted` | `--sg-fg`, `--sg-muted` |\n| `border/border` | `--sg-border` |\n| `accent/accent` | `--sg-accent` |\n| `header/bg` | `--sg-header-bg` |\n| `row/selection-bg` | `--sg-selection-bg` |\n| `pinned/bg` | `--sg-pinned-bg` |\n| `status/danger` | `--sg-danger` |\n| `alpha/success-14` | `color-mix(in srgb, var(--sg-success) 14%, transparent)` |\n\nTranslucent fills, such as the soft badge and chip backgrounds, are\n`alpha/*` variables whose code is the `color-mix()` the CSS writes.\n\n## Themes and density\n\nThe `SvGrid` collection has one mode per theme: Ember (the default) and\nshadcn/ui, each light and dark. Set the mode on a frame to re-theme\neverything inside it. In code, that is the theme stylesheet plus the\n`data-theme` attribute on `<html>` for dark:\n\n```css\n@import '@svgrid/grid/themes/ember.css';\n```\n\nThe `SvGrid density` collection holds row height, header height and cell\npadding:\n\n| Variable | Default | Comfortable | In code |\n|---|---|---|---|\n| `grid/row-height` | 30 | 36 | `rowHeight` prop |\n| `grid/header-height` | 22 (content-sized) | 48 | `--sg-header-min-height` |\n| `grid/cell-px` | 7 | 12 | `--sg-cell-px` |\n\nA design in Comfortable density translates to:\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n import '@svgrid/grid/themes/ember.css'\n\n type Order = { id: string; customer: string; total: number }\n const rows: Order[] = [{ id: '#10482', customer: 'Tailspin Toys', total: 1284 }]\n const columns: GridColumns<Order> = [\n { field: 'id', header: 'Order', width: 110 },\n { field: 'customer', header: 'Customer', width: 210 },\n { field: 'total', header: 'Total', width: 120, align: 'right' },\n ]\n</script>\n\n<div style=\"--sg-cell-px: 12px; --sg-header-min-height: 48px\">\n <SvGrid data={rows} {columns} rowHeight={36} showRowSelection pageable />\n</div>\n```\n\nThe other 18 theme presets are not in the Community file. See\n[Design tokens](./tokens.md) for every token and\n[Theme and density](../getting-started/5-theme-and-density.md) for the\npresets.\n\n## Components map to props\n\nVariant properties in the kit follow the Svelte props. A `Button` instance\nset to `Variant=Secondary, Size=sm` is `<SvButton variant=\"secondary\"\nsize=\"sm\">`. `State` (Hover, Focus, Disabled, ...) shows how the component\nlooks in that state; in code most states come from interaction, and\n`disabled`, `invalid` and `loading` are props.\n\n## Where the kit differs from a browser\n\n- Inter stands in for the system font stack (`ui-sans-serif, system-ui`).\n- The grid takes its font size from the page it sits on. The kit uses 13px,\n the size of the grid's own pager, menus and filter inputs.\n- Heights assume a border-box reset, as most app shells and svgrid.com have.\n- Alert icons and pager arrows are text characters in the CSS and render in\n Inter in the kit.\n\n## Rebuilding the kit\n\nThe plugin that builds the file lives in `tools/figma`. To build it with\nother theme presets, for example for your own team library:\n\n```bash\npnpm figma:build\nnode tools/figma/build.mjs --themes=ember,material,excel\n```\n\nThen, in the Figma desktop app, open an empty design file, choose\nPlugins > Development > Import plugin from manifest, pick\n`tools/figma/dist/manifest.json`, and run it. `tools/figma/README.md` covers\nplan limits and how the build is checked.\n"
4522
+ },
4508
4523
  {
4509
4524
  "slug": "help/filtering/advanced-filter",
4510
4525
  "path": "docs/help/filtering/advanced-filter.md",
@@ -4650,7 +4665,7 @@ export const docs = [
4650
4665
  "path": "docs/help/headless/virtualization.md",
4651
4666
  "title": "Headless virtualization",
4652
4667
  "section": "Headless",
4653
- "markdown": "# Headless virtualization\n\n`<SvGrid>` virtualizes rows and columns for you. When you render your own\nmarkup, you virtualize yourself with the same engine SvGrid uses -\n`createSvelteVirtualizer` (reactive) or `createVirtualizer` (framework-agnostic).\nOnly the rows in view get DOM nodes, so 100k rows stay smooth.\n\n<div data-docs-demo=\"187-headless-virtual\" data-height=\"500\"></div>\n\n## The idea\n\nA virtualizer answers two questions as the user scrolls: **which items are\nvisible**, and **how far to offset them**. You give it the item `count`, an\n`estimateSize` (row height), and the `viewportHeight`; it gives you the virtual\nitems to render plus the total size to reserve. You feed it the live scroll\nposition with `setScrollOffset`, and read `version` so your derived values\nrecompute.\n\n```svelte\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSvelteVirtualizer,\n tableFeatures,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Row = { id: number; name: string; score: number }\n const data: Row[] = Array.from({ length: 100_000 }, (_, i) => ({\n id: i, name: `Item ${i}`, score: (i * 37) % 1000,\n }))\n const features = tableFeatures({}) // core only - nothing to sort or filter\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'name', header: 'Name' },\n { field: 'score', header: 'Score' },\n ]\n\n const table = createSvGrid({\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Row>() },\n data, columns,\n })\n const rows = table.getRowModel().rows\n\n const ROW_H = 34\n const VIEWPORT_H = 420\n const virtualizer = createSvelteVirtualizer({\n count: rows.length, // a number (call setOptions({ count }) when it changes)\n estimateSize: ROW_H, // number, or (index) => px for variable heights\n overscan: 8,\n viewportHeight: VIEWPORT_H,\n })\n\n // Reading `version` makes these recompute whenever the virtualizer updates.\n const items = $derived.by(() => { virtualizer.version; return virtualizer.getVirtualItems() })\n const totalSize = $derived.by(() => { virtualizer.version; return virtualizer.getTotalSize() })\n\n const onScroll = (e: Event) =>\n virtualizer.setScrollOffset((e.currentTarget as HTMLElement).scrollTop)\n</script>\n```\n\n## Render only what's visible\n\nReserve the full scroll height with a spacer, then absolutely-position each\nvisible row at its `start` offset:\n\n```svelte\n<div style={`height: ${VIEWPORT_H}px; overflow: auto; position: relative;`} onscroll={onScroll}>\n <!-- Spacer reserves the full height so the scrollbar is correct. -->\n <div style={`height: ${totalSize}px; position: relative;`}>\n {#each items as vi (vi.key)}\n {@const row = rows[vi.index].original as Row}\n <div style={`position: absolute; top: 0; left: 0; width: 100%;\n height: ${ROW_H}px; transform: translateY(${vi.start}px);`}>\n {row.name} - {row.score}\n </div>\n {/each}\n </div>\n</div>\n```\n\n`getVirtualItems()` returns only the ~20 rows in view (plus `overscan`), each\nwith `index`, `start` (px offset), `size`, and `key`. As the user scrolls,\n`setScrollOffset` bumps `version` and the list re-computes; the other 99,980\nrows never touch the DOM.\n\n## Columns too\n\nFor very wide grids, `createColumnVirtualizer` does the same across leaf columns\n(horizontal). Drive it with `setScrollOffset(scrollLeft)` on the container's\nhorizontal scroll:\n\n```ts\nconst colVirtualizer = createColumnVirtualizer({\n count: leafColumns.length,\n viewportWidth: containerWidth,\n estimateSize: (i) => leafColumns[i].getSize(),\n overscan: 3,\n})\n```\n\n## Which one to use\n\n| Export | Use when |\n| --- | --- |\n| `createSvelteVirtualizer` | Inside a Svelte component - exposes a reactive `version` to re-derive from. |\n| `createVirtualizer` | Framework-agnostic - a non-Svelte custom layer / worker (subscribe manually). |\n| `createColumnVirtualizer` | Horizontal (column) virtualization. |\n\n`VirtualItem`, `VirtualizerOptions`, and `VirtualizerState` are exported for\ntype annotations.\n\n## It is already on\n\nRow virtualization is the default, which is why a large grid needs no\nconfiguration to stay responsive. The DOM holds the visible window, not the row\ncount - open the inspector on the grid below and you will find a few dozen rows,\nnot five thousand.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Row = { id: number; label: string; n: number }\n\n const many: Row[] = Array.from({ length: 5000 }, (_, i) => ({\n id: i,\n label: 'Row ' + i,\n n: (i * 37) % 1000,\n }))\n\n const columns: GridColumns<Row> = [\n { field: 'label', header: 'Label', width: 200 },\n { field: 'n', header: 'Value', width: 120 },\n ]\n</script>\n\n<SvGrid data={many} {columns} sortable />\n```\n\n\n## Columns need their own opt-in\n\nRow virtualization still renders every column of every visible row, so a wide\ngrid stays slow until you add `columnVirtualization`. The trade is real:\noff-screen columns are not in the DOM, so anything that walks the row's cells -\na stylesheet selecting the nth child, a screenshot test - sees only the window.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Wide = Record<string, number>\n\n const names = Array.from({ length: 60 }, (_, i) => 'c' + i)\n\n const wide: Wide[] = Array.from({ length: 2000 }, (_, r) => {\n const row: Wide = { id: r }\n names.forEach((name, c) => (row[name] = (r * (c + 1)) % 500))\n return row\n })\n\n const columns: GridColumns<Wide> = names.map((name) => ({\n field: name,\n header: name.toUpperCase(),\n width: 90,\n }))\n</script>\n\n<SvGrid data={wide} {columns} columnVirtualization />\n```\n\n## See also\n\n- [Build a table from scratch](./build-a-table.md) - render non-virtualized first\n- [Row models](./row-models.md)\n- [Benchmarks](../benchmarks.md) - the 1M-row numbers\n"
4668
+ "markdown": "# Headless virtualization\n\n`<SvGrid>` virtualizes rows and columns for you. When you render your own\nmarkup, you virtualize yourself with the same engine SvGrid uses -\n`createSvelteVirtualizer` (reactive) or `createVirtualizer` (framework-agnostic).\nOnly the rows in view get DOM nodes, so 100k rows stay smooth.\n\n<div data-docs-demo=\"187-headless-virtual\" data-height=\"500\"></div>\n\n## The idea\n\nA virtualizer answers two questions as the user scrolls: **which items are\nvisible**, and **how far to offset them**. You give it the item `count`, an\n`estimateSize` (row height), and the `viewportHeight`; it gives you the virtual\nitems to render plus the total size to reserve. You feed it the live scroll\nposition with `setScrollOffset`, and read `version` so your derived values\nrecompute.\n\n```svelte\n<script lang=\"ts\">\n import {\n createSvGrid,\n createCoreRowModel,\n createSvelteVirtualizer,\n tableFeatures,\n type ColumnDef,\n } from '@svgrid/grid/core'\n\n type Row = { id: number; name: string; score: number }\n const data: Row[] = Array.from({ length: 100_000 }, (_, i) => ({\n id: i, name: `Item ${i}`, score: (i * 37) % 1000,\n }))\n const features = tableFeatures({}) // core only - nothing to sort or filter\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'name', header: 'Name' },\n { field: 'score', header: 'Score' },\n ]\n\n const table = createSvGrid({\n _features: features,\n _rowModels: { coreRowModel: createCoreRowModel<Row>() },\n data, columns,\n })\n const rows = table.getRowModel().rows\n\n const ROW_H = 34\n const VIEWPORT_H = 420\n const virtualizer = createSvelteVirtualizer({\n count: rows.length, // a number (call setOptions({ count }) when it changes)\n estimateSize: ROW_H, // number, or (index) => px for variable heights\n overscan: 8,\n viewportHeight: VIEWPORT_H,\n })\n\n // Reading `version` makes these recompute whenever the virtualizer updates.\n const items = $derived.by(() => { virtualizer.version; return virtualizer.getVirtualItems() })\n const totalSize = $derived.by(() => { virtualizer.version; return virtualizer.getTotalSize() })\n\n const onScroll = (e: Event) =>\n virtualizer.setScrollOffset((e.currentTarget as HTMLElement).scrollTop)\n</script>\n```\n\n## Render only what's visible\n\nReserve the full scroll height with a spacer, then absolutely-position each\nvisible row at its `start` offset:\n\n```svelte\n<div style={`height: ${VIEWPORT_H}px; overflow: auto; position: relative;`} onscroll={onScroll}>\n <!-- Spacer reserves the full height so the scrollbar is correct. -->\n <div style={`height: ${totalSize}px; position: relative;`}>\n {#each items as vi (vi.key)}\n {@const row = rows[vi.index].original as Row}\n <div style={`position: absolute; top: 0; left: 0; width: 100%;\n height: ${ROW_H}px; transform: translateY(${vi.start}px);`}>\n {row.name} - {row.score}\n </div>\n {/each}\n </div>\n</div>\n```\n\n`getVirtualItems()` returns only the ~20 rows in view (plus `overscan`), each\nwith `index`, `start` (px offset), `size`, and `key`. As the user scrolls,\n`setScrollOffset` bumps `version` and the list re-computes; the other 99,980\nrows never touch the DOM.\n\n## Columns too\n\nFor very wide grids, `createColumnVirtualizer` does the same across leaf columns\n(horizontal). Drive it with `setScrollOffset(scrollLeft)` on the container's\nhorizontal scroll:\n\n```ts\nconst colVirtualizer = createColumnVirtualizer({\n count: leafColumns.length,\n viewportWidth: containerWidth,\n estimateSize: (i) => leafColumns[i].getSize(),\n overscan: 3,\n})\n```\n\n## Moving the window less often\n\nTwo options shape how the window follows a scroll. `overscanBehind` keeps\nfewer items on the side the scroll is leaving, while the full `overscan`\nstays ahead of it. `overscanMin` keeps the window where it is while at least\nthat many items are still rendered ahead of the scroll, and moves it only\nwhen the scroll runs past them, with the full `overscan` ahead again:\n\n```ts\nconst colVirtualizer = createColumnVirtualizer({\n count: leafColumns.length,\n viewportWidth: containerWidth,\n estimateSize: (i) => leafColumns[i].getSize(),\n overscan: 5, // ahead of the scroll right after a move\n overscanBehind: 1, // behind it\n overscanMin: 2, // never fewer ahead; the window moves 4 items at a time\n})\n```\n\nMoving a column window touches every rendered row, about the same work\nwhether one column enters or four, so a few larger moves cost less than many\none-column moves, and the scroll frames between moves change nothing.\n`<SvGrid>` runs its column window this way. A window built at rest, or after\na jump to items outside the rendered window, carries `overscanMin`.\n\nWhile the window holds, `version` does not change and `getState().scrollOffset`\nkeeps the offset of the last move. Read the scroll position from your\ncontainer if something else has to follow it every frame.\n\n## Which one to use\n\n| Export | Use when |\n| --- | --- |\n| `createSvelteVirtualizer` | Inside a Svelte component - exposes a reactive `version` to re-derive from. |\n| `createVirtualizer` | Framework-agnostic - a non-Svelte custom layer / worker (subscribe manually). |\n| `createColumnVirtualizer` | Horizontal (column) virtualization. |\n\n`VirtualItem`, `VirtualizerOptions`, and `VirtualizerState` are exported for\ntype annotations.\n\n## It is already on\n\nRow virtualization is the default, which is why a large grid needs no\nconfiguration to stay responsive. The DOM holds the visible window, not the row\ncount - open the inspector on the grid below and you will find a few dozen rows,\nnot five thousand.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Row = { id: number; label: string; n: number }\n\n const many: Row[] = Array.from({ length: 5000 }, (_, i) => ({\n id: i,\n label: 'Row ' + i,\n n: (i * 37) % 1000,\n }))\n\n const columns: GridColumns<Row> = [\n { field: 'label', header: 'Label', width: 200 },\n { field: 'n', header: 'Value', width: 120 },\n ]\n</script>\n\n<SvGrid data={many} {columns} sortable />\n```\n\n\n## Columns need their own opt-in\n\nRow virtualization still renders every column of every visible row, so a wide\ngrid stays slow until you add `columnVirtualization`. The trade is real:\noff-screen columns are not in the DOM, so anything that walks the row's cells -\na stylesheet selecting the nth child, a screenshot test - sees only the window.\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns } from '@svgrid/grid'\n\n type Wide = Record<string, number>\n\n const names = Array.from({ length: 60 }, (_, i) => 'c' + i)\n\n const wide: Wide[] = Array.from({ length: 2000 }, (_, r) => {\n const row: Wide = { id: r }\n names.forEach((name, c) => (row[name] = (r * (c + 1)) % 500))\n return row\n })\n\n const columns: GridColumns<Wide> = names.map((name) => ({\n field: name,\n header: name.toUpperCase(),\n width: 90,\n }))\n</script>\n\n<SvGrid data={wide} {columns} columnVirtualization />\n```\n\n## See also\n\n- [Build a table from scratch](./build-a-table.md) - render non-virtualized first\n- [Row models](./row-models.md)\n- [Benchmarks](../benchmarks.md) - the 1M-row numbers\n"
4654
4669
  },
4655
4670
  {
4656
4671
  "slug": "help/i18n-rtl",
@@ -4692,7 +4707,7 @@ export const docs = [
4692
4707
  "path": "docs/help/llm-grounding.md",
4693
4708
  "title": "Use sv-grid docs as LLM context",
4694
4709
  "section": "Help",
4695
- "markdown": "# Use sv-grid docs as LLM context\n\nThis page is the \"how do I make ChatGPT / Claude / Cursor write good\nsv-grid code?\" guide. Three pre-built artefacts ship with the docs\nspecifically so models can ground themselves in current, accurate\ninformation instead of hallucinating from training data.\n\n## The four files\n\n| File | Format | Size | Use for |\n| ---------------------------------------- | ---------- | ------ | ---------------------------------------------------------------------- |\n| [`/llms.txt`](/llms.txt) | Plain text | ~10 kB | First-pass context: the topic map with one-line summaries |\n| [`/llms-full.txt`](/llms-full.txt) | Plain text | ~700 kB | Deep grounding: every doc page concatenated |\n| [`/docs.json`](/docs.json) | JSON | ~80 kB | Programmatic crawling: section tree, per-page metadata, demo links |\n| [`/schemas/index.json`](/schemas/index.json) | JSON | ~30 kB | Validation: machine-checkable shape of `ColumnDef`, `<SvGrid>` props, export options |\n\nAll four are regenerated on every commit by `tools/build-docs-index.mjs`\nand `tools/build-schemas.mjs`. They live at the docs origin\n(`https://svgrid.com/...`) so you can fetch them at runtime.\n\n## Recipe 1: Drop into a custom GPT / Claude project\n\nThe simplest way. Both ChatGPT (custom GPTs) and Claude (projects)\nlet you upload reference files that ride along with every chat.\n\n1. Save [`/llms-full.txt`](/llms-full.txt) locally.\n2. In ChatGPT: *Create custom GPT → Configure → Knowledge → Upload files*.\n3. In Claude: *Project → Project knowledge → Add document*.\n4. Add this system instruction:\n\n```\nYou are a sv-grid expert. Ground every answer in the attached\nllms-full.txt. If a question references an API not in the document,\nsay so and ask the user to upgrade rather than inventing one. Prefer\nthe smallest working example. When showing columns, follow the\ncolumn-def.json schema exactly.\n```\n\n5. (Optional) Upload `column-def.json` and `svgrid-options.json`\n alongside so the model can self-check generated config.\n\nThat's it. The next time you ask \"how do I export only selected rows\nto xlsx?\" the model answers from the doc text, not from its\nyear-old training cutoff.\n\n## Recipe 2: Cursor / Continue / Cody rules file\n\nMost IDE assistants honour a `.cursorrules` / `.continuerules` /\n`.aider.conf.yml` file in the repo root. Drop in:\n\n```\n# .cursorrules\n\nWhen generating sv-grid code:\n- Read context from https://svgrid.com/llms.txt before answering.\n- For column definitions, generate against\n https://svgrid.com/schemas/column-def.json (Draft 2020-12 JSON Schema).\n- Use Svelte 5 runes ($state, $derived, $effect) - never legacy stores.\n- Use `editorType: 'list'` with `editorOptions` for dropdowns,\n not raw <select> elements.\n- Always type the grid as\n `SvGrid<typeof features, RowType>` so column inference works.\n- The two npm packages are `@svgrid/grid` (MIT) and `@svgrid/enterprise`\n (commercial). Never import from `@sv-grid/core` or `svelte-grid`,\n which are different projects.\n```\n\n## Recipe 3: Programmatic grounding in your own agent\n\nIf you're building a custom agent (OpenAI Agents SDK, Anthropic SDK,\nLangChain, custom), fetch the docs once at boot:\n\n```ts\nconst [topicMap, schemas] = await Promise.all([\n fetch('https://svgrid.com/llms.txt').then((r) => r.text()),\n fetch('https://svgrid.com/schemas/index.json').then((r) => r.json()),\n])\n\nconst systemPrompt = `You write Svelte 5 code that uses sv-grid.\n\nDOCS INDEX (use these URLs to look up specifics):\n${topicMap}\n\nSCHEMAS available for validation:\n${JSON.stringify(schemas, null, 2)}\n\nFor deep API questions, fetch https://svgrid.com/llms-full.txt or\nthe specific page from the index above.`\n```\n\nNow hand the model a tool that can fetch arbitrary `/docs.json` paths\non demand, and it can answer any sv-grid question with current data.\n\n## Recipe 4: MCP server (best for daily-driver chat)\n\nIf your workflow centers on Claude Desktop / Cursor / Zed, the\n[MCP server](./mcp-server.md) is the single line of config that\nexposes the same grounding PLUS callable tools (`svgrid_search`,\n`svgrid_get`, `svgrid_check_code`, and the SvGrid\nStudio generators). Skip Recipes 1-3 and use the MCP server instead.\n\n## What's IN the grounding files\n\nEvery file is exhaustive but tightly scoped to sv-grid surface area:\n\n- **API surface**: every prop on `<SvGrid>`, every method on\n `SvGridApi`, every field on `ColumnDef`\n- **Features**: when to use sorting / filtering / grouping / pagination\n feature toggles, and the trade-offs\n- **Enterprise tier**: export, import, pivot - each documented as\n if it were free, with the licensing call-out at the top of the page\n- **Recipes**: 25+ copy-paste patterns from the cookbook\n- **Migrations**: how to translate concepts from other data grids\n- **Errors**: every typed error the library throws, with the trigger\n and the fix\n\n## What's NOT in the grounding files\n\n- **Internal implementation**: virtualizer math, headless engine\n pipeline internals - not part of the public surface\n- **Future / roadmap**: deliberately excluded so the model never\n confuses ambition with reality\n- **CSS class hashes**: Svelte mangles class names. The\n `--sg-*` tokens are stable and documented; the class names are not.\n\n## Keeping the grounding fresh\n\nRe-fetch on every model turn for chat tools; cache for ~24h for\nagent loops. The docs are versioned - if you pin to a specific\nversion, append a `?v=1.6.0` query string when fetching from the\norigin (rejected if the major changes; we serve a 410).\n\n## See also\n\n- [MCP server](./mcp-server.md) - the easiest way to wire all this in\n- [Agents](./agents.md) - building an agent that DRIVES the grid (not just describes it)\n- [API stability](./api-stability.md) - what we promise to keep stable across versions\n"
4710
+ "markdown": "# Use sv-grid docs as LLM context\n\nThis page is the \"how do I make ChatGPT / Claude / Cursor write good\nsv-grid code?\" guide. Pre-built files ship with the docs so models can\nground themselves in current, accurate information instead of\nhallucinating from training data.\n\n## Make the assistant reach for SvGrid\n\nAsk a coding assistant for \"a table with sort, filter and editing\" in a\nfresh SvelteKit project and it writes its own `DataTable.svelte`. It does\nthat even for 10,000 rows, because it avoids adding a dependency nobody\nnamed. What changes the outcome is what the project already says. In\nfresh-session tests, Claude Code used `<SvGrid>` every time when either\n`@svgrid/grid` was in `package.json` or the project's `AGENTS.md` carried\nthis section:\n\n```md\n## Tables and data grids\n\nThis project uses SvGrid (`@svgrid/grid`, MIT, Svelte 5) for tables and data grids. For any table that sorts, filters, edits, pages, groups or holds many rows, use `<SvGrid>` instead of writing a table component.\n\n- Features are boolean props, all off by default: `<SvGrid {data} {columns} sortable filterable editable pageable />`.\n- Type the columns with `GridColumns<Row>` from `@svgrid/grid`, so each `field` is checked against the row type.\n- Exact props and types: `node_modules/@svgrid/grid/dist/*.d.ts`. Docs written for agents: https://svgrid.com/llms.txt\n```\n\nYou do not have to paste it yourself. `npx sv add @svgrid` adds the\ndependency and offers to write the section, and every\n`npm create @svgrid@latest` template starts with it. For all your\nprojects at once, install the Agent Skill: `npx skills add sv-grid/sv-grid`.\n\n## The files\n\n| File | Use for |\n| ---- | ------- |\n| [`/llms.txt`](/llms.txt) | First read: when to use SvGrid, a quick start, the free / paid line, then every page with a one-line summary |\n| [`/llms-small.txt`](/llms-small.txt) | One fetch: the same opening plus the full text of the getting-started path and the pages asked about most |\n| [`/llms-full.txt`](/llms-full.txt) | Deep grounding: every doc page, the comparison pages and the API reference |\n| `/docs/<page>.md` | One page as plain markdown: any docs URL with the trailing slash replaced by `.md` |\n| [`/docs.json`](/docs.json) | Programmatic crawling: section tree, per-page metadata, demo links |\n| [`/schemas/index.json`](/schemas/index.json) | Validation: machine-checkable shape of `ColumnDef`, `<SvGrid>` props, export options |\n\nThey are regenerated on every build by `tools/build-docs-index.mjs`,\n`tools/build-schemas.mjs` and the prerenderer. They live at the docs\norigin (`https://svgrid.com/...`) so you can fetch them at runtime.\n\n## Recipe 1: Drop into a custom GPT / Claude project\n\nThe simplest way. Both ChatGPT (custom GPTs) and Claude (projects)\nlet you upload reference files that ride along with every chat.\n\n1. Save [`/llms-full.txt`](/llms-full.txt) locally.\n2. In ChatGPT: *Create custom GPT → Configure → Knowledge → Upload files*.\n3. In Claude: *Project → Project knowledge → Add document*.\n4. Add this system instruction:\n\n```\nYou are a sv-grid expert. Ground every answer in the attached\nllms-full.txt. If a question references an API not in the document,\nsay so and ask the user to upgrade rather than inventing one. Prefer\nthe smallest working example. When showing columns, follow the\ncolumn-def.json schema exactly.\n```\n\n5. (Optional) Upload `column-def.json` and `svgrid-options.json`\n alongside so the model can self-check generated config.\n\nThat's it. The next time you ask \"how do I export only selected rows\nto xlsx?\" the model answers from the doc text, not from its\nyear-old training cutoff.\n\n## Recipe 2: Cursor / Continue / Cody rules file\n\nMost IDE assistants honour a `.cursorrules` / `.continuerules` /\n`.aider.conf.yml` file in the repo root. Drop in:\n\n```\n# .cursorrules\n\nWhen generating sv-grid code:\n- Read context from https://svgrid.com/llms.txt before answering.\n- For column definitions, generate against\n https://svgrid.com/schemas/column-def.json (Draft 2020-12 JSON Schema).\n- Use Svelte 5 runes ($state, $derived, $effect) - never legacy stores.\n- Use `editorType: 'list'` with `editorOptions` for dropdowns,\n not raw <select> elements.\n- Always type the grid as\n `SvGrid<typeof features, RowType>` so column inference works.\n- The two npm packages are `@svgrid/grid` (MIT) and `@svgrid/enterprise`\n (commercial). Never import from `@sv-grid/core` or `svelte-grid`,\n which are different projects.\n```\n\n## Recipe 3: Programmatic grounding in your own agent\n\nIf you're building a custom agent (OpenAI Agents SDK, Anthropic SDK,\nLangChain, custom), fetch the docs once at boot:\n\n```ts\nconst [topicMap, schemas] = await Promise.all([\n fetch('https://svgrid.com/llms.txt').then((r) => r.text()),\n fetch('https://svgrid.com/schemas/index.json').then((r) => r.json()),\n])\n\nconst systemPrompt = `You write Svelte 5 code that uses sv-grid.\n\nDOCS INDEX (use these URLs to look up specifics):\n${topicMap}\n\nSCHEMAS available for validation:\n${JSON.stringify(schemas, null, 2)}\n\nFor deep API questions, fetch https://svgrid.com/llms-full.txt or\nthe specific page from the index above.`\n```\n\nNow hand the model a tool that can fetch arbitrary `/docs.json` paths\non demand, and it can answer any sv-grid question with current data.\n\n## Recipe 4: MCP server (best for daily-driver chat)\n\nIf your workflow centers on Claude Desktop / Cursor / Zed, the\n[MCP server](./mcp-server.md) is the single line of config that\nexposes the same grounding PLUS callable tools (`svgrid_search`,\n`svgrid_get`, `svgrid_check_code`, and the SvGrid\nStudio generators). Skip Recipes 1-3 and use the MCP server instead.\n\n## What's IN the grounding files\n\nEvery file is exhaustive but tightly scoped to sv-grid surface area:\n\n- **API surface**: every prop on `<SvGrid>`, every method on\n `SvGridApi`, every field on `ColumnDef`\n- **Features**: when to use sorting / filtering / grouping / pagination\n feature toggles, and the trade-offs\n- **Enterprise tier**: export, import, pivot - each documented as\n if it were free, with the licensing call-out at the top of the page\n- **Recipes**: 25+ copy-paste patterns from the cookbook\n- **Migrations**: how to translate concepts from other data grids\n- **Errors**: every typed error the library throws, with the trigger\n and the fix\n\n## What's NOT in the grounding files\n\n- **Internal implementation**: virtualizer math, headless engine\n pipeline internals - not part of the public surface\n- **Future / roadmap**: deliberately excluded so the model never\n confuses ambition with reality\n- **CSS class hashes**: Svelte mangles class names. The\n `--sg-*` tokens are stable and documented; the class names are not.\n\n## Keeping the grounding fresh\n\nRe-fetch on every model turn for chat tools; cache for ~24h for\nagent loops. The docs are versioned - if you pin to a specific\nversion, append a `?v=1.6.0` query string when fetching from the\norigin (rejected if the major changes; we serve a 410).\n\n## See also\n\n- [MCP server](./mcp-server.md) - the easiest way to wire all this in\n- [Agents](./agents.md) - building an agent that DRIVES the grid (not just describes it)\n- [API stability](./api-stability.md) - what we promise to keep stable across versions\n"
4696
4711
  },
4697
4712
  {
4698
4713
  "slug": "help/mcp-server",
@@ -4993,7 +5008,7 @@ export const docs = [
4993
5008
  "path": "docs/help/rows/styling-rows.md",
4994
5009
  "title": "Styling rows",
4995
5010
  "section": "Rows",
4996
- "markdown": "# Styling rows\n\nRows are `<tr role=\"row\">` elements inside the grid table. Style them with\nplain CSS.\n<div data-docs-demo=\"62-conditional-styling\" data-height=\"540\"></div>\n\n## Zebra striping\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190, editorType: 'text' },\n { field: 'department', header: 'Department', width: 150, editorType: 'text' },\n { field: 'city', header: 'City', width: 130, editorType: 'text' },\n { field: 'age', header: 'Age', width: 80, editorType: 'number' },\n { field: 'salary', header: 'Salary', width: 130, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\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\n`<SvGrid>` accepts a `rowClass` callback. Return a string, an array\nof strings, or a `Record<string, boolean>`, and the classes are\nadded to the `<tr>`:\n\n```svelte\n<SvGrid\n data={rows}\n {columns}\n features={features}\n rowClass={({ row }) => ({\n 'is-overdue': row.dueDate < new Date().toISOString().slice(0, 10),\n 'is-cancelled': row.status === 'cancelled',\n })}\n/>\n\n<style>\n :global(tr.is-overdue .sv-grid-cell) { background: rgba(220, 38, 38, 0.06); }\n :global(tr.is-cancelled .sv-grid-cell) { color: var(--sg-muted); text-decoration: line-through; }\n</style>\n```\n\nThe callback receives `{ row, rowIndex }` - the un-mutated source\nrow + its data-array index. Runs per visible row on every render, so\nkeep the body cheap (string lookups, equality checks - no `.find()`\nover the whole dataset).\n\nFor one-cell tints, use `cellClass` on the column def - same shape,\ncalled per cell with the standard `CellContext`. See\n[Styling cells](../cells/styling-cells.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-focus-ring focus outline (box-shadow)\n--sg-accent primary accent (sort arrow, etc)\n```\n\n## More examples\n\n### Zebra rows\n\nThe `zebraRows` prop stripes every other DATA row with the theme's --sg-row-alt-bg token. Pinned, group, detail and summary rows keep their single background, so a pinned total row still reads as one solid band. The stripe follows whatever preset or dark mode the page is on.\n\n<div data-docs-demo=\"174-zebra-rows\" data-height=\"460\"></div>\n\n## Try it\n\n`rowClass` receives the row and its index and returns whatever `class:` accepts -\na string, a list, or an object of conditions. Driving it from the data rather\nthan the index is what keeps the styling correct after a sort.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n sortable\n rowClass={({ row }) => ({\n 'is-senior': row.age >= 50,\n 'is-costly': row.salary > 160000,\n })}\n/>\n\n<style>\n :global(.is-senior) { font-weight: 600; }\n :global(.is-costly) { background: color-mix(in srgb, #f59e0b 12%, transparent); }\n</style>\n```\n\n## See also\n\n- [Row height](./row-height.md)\n- [Custom cells](../cells/cell-components.md)\n"
5011
+ "markdown": "# Styling rows\n\nRows are `<tr role=\"row\">` elements inside the grid table. Style them with\nplain CSS.\n<div data-docs-demo=\"62-conditional-styling\" data-height=\"540\"></div>\n\n## Zebra striping\n\nThe examples on this page run against these rows:\n\n```svelte {preamble}\n<script lang=\"ts\">\n import { SvGrid, type GridColumns, type SvGridApi } from '@svgrid/grid'\n\n type Person = {\n id: number\n name: string\n department: string\n city: string\n age: number\n salary: number\n }\n\n const people: Person[] = [\n { id: 1, name: 'Ada Lovelace', department: 'Engineering', city: 'London', age: 36, salary: 142000 },\n { id: 2, name: 'Grace Hopper', department: 'Engineering', city: 'New York', age: 45, salary: 168000 },\n { id: 3, name: 'Linus Torvalds', department: 'Platform', city: 'Portland', age: 54, salary: 155000 },\n { id: 4, name: 'Radia Perlman', department: 'Networking', city: 'Seattle', age: 49, salary: 161000 },\n { id: 5, name: 'Barbara Liskov', department: 'Platform', city: 'Boston', age: 52, salary: 172000 },\n ]\n\n const columns: GridColumns<Person> = [\n { field: 'name', header: 'Name', width: 190, editorType: 'text' },\n { field: 'department', header: 'Department', width: 150, editorType: 'text' },\n { field: 'city', header: 'City', width: 130, editorType: 'text' },\n { field: 'age', header: 'Age', width: 80, editorType: 'number' },\n { field: 'salary', header: 'Salary', width: 130, editorType: 'number', format: { type: 'currency', currency: 'USD' } },\n ]\n</script>\n```\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\n`<SvGrid>` accepts a `rowClass` callback. Return a string, an array\nof strings, or a `Record<string, boolean>`, and the classes are\nadded to the `<tr>`:\n\n```svelte\n<SvGrid\n data={rows}\n {columns}\n features={features}\n rowClass={({ row }) => ({\n 'is-overdue': row.dueDate < new Date().toISOString().slice(0, 10),\n 'is-cancelled': row.status === 'cancelled',\n })}\n/>\n\n<style>\n :global(tr.is-overdue .sv-grid-cell) { background: rgba(220, 38, 38, 0.06); }\n :global(tr.is-cancelled .sv-grid-cell) { color: var(--sg-muted); text-decoration: line-through; }\n</style>\n```\n\nThe callback receives `{ row, rowIndex }` - the un-mutated source\nrow + its data-array index. Runs per visible row on every render, so\nkeep the body cheap (string lookups, equality checks - no `.find()`\nover the whole dataset).\n\nFor one-cell tints, use `cellClass` on the column def - same shape,\ncalled per cell with the standard `CellContext`. See\n[Styling cells](../cells/styling-cells.md).\n\n## Row layout\n\nThe markup is a `<table>`, but each `<tr>` is laid out as a flex row of\nfixed-width cells (`display: flex`), not by the table algorithm. On a\nhorizontal scroll the grid adds and removes a column of cells every frame.\nA table re-lays every row for that; a flex row re-lays only its own cells.\nOn the 503 big-data demo at 10,000 columns that is about 1 ms of layout a\nframe instead of 2.7 ms.\n\nWhat this changes for your CSS:\n\n- A body cell's content sits in `<span class=\"sv-grid-cell-line\">`, a\n block that fills the cell. `text-align` on the cell still aligns it, and\n the ellipsis lives on that span.\n- `vertical-align` on a cell does nothing. Cells centre their content with\n `align-items: center`; set `align-items` on the cell to change it.\n- A row's `height` is exact, not a minimum. A row is as tall as\n `rowHeight` says, because that is where the virtualizer places it, and\n content taller than that is clipped instead of growing the row. Raise\n `rowHeight` for taller content, or set `autoRowHeight` to let each row\n grow to fit.\n\n`rowLayout=\"table\"` brings back table layout. The grid uses it on its own\nwhen `mergedCells` holds a merge with `rowSpan` above 1, since a cell\nspanning rows needs the table algorithm, and under the\n`use:spreadsheetLayout` action.\n\n```css\n/* Top-align the cells of one grid. */\n.my-grid .sv-grid-cell {\n align-items: flex-start;\n}\n```\n\nBody rows are not CSS-contained by default. `--sg-row-contain` sets a\n`contain` value on every body row if your app wants one:\n\n```css\n.my-grid {\n --sg-row-contain: layout paint;\n}\n```\n\nWe measured `layout paint` and left it off: each contained row becomes a\nseparate clipped piece of paint, and Chrome redoes its layer work for\nevery one of them on each scroll frame, which doubled the main-thread\nwork of a horizontal scroll on a 1,000-column grid. It also makes a row\nthe containing block for `position: fixed` content inside it.\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-focus-ring focus outline (box-shadow)\n--sg-accent primary accent (sort arrow, etc)\n```\n\n## More examples\n\n### Zebra rows\n\nThe `zebraRows` prop stripes every other DATA row with the theme's --sg-row-alt-bg token. Pinned, group, detail and summary rows keep their single background, so a pinned total row still reads as one solid band. The stripe follows whatever preset or dark mode the page is on.\n\n<div data-docs-demo=\"174-zebra-rows\" data-height=\"460\"></div>\n\n## Try it\n\n`rowClass` receives the row and its index and returns whatever `class:` accepts -\na string, a list, or an object of conditions. Driving it from the data rather\nthan the index is what keeps the styling correct after a sort.\n\n```svelte {runnable}\n<SvGrid\n data={people}\n {columns}\n sortable\n rowClass={({ row }) => ({\n 'is-senior': row.age >= 50,\n 'is-costly': row.salary > 160000,\n })}\n/>\n\n<style>\n :global(.is-senior) { font-weight: 600; }\n :global(.is-costly) { background: color-mix(in srgb, #f59e0b 12%, transparent); }\n</style>\n```\n\n## See also\n\n- [Row height](./row-height.md)\n- [Custom cells](../cells/cell-components.md)\n"
4997
5012
  },
4998
5013
  {
4999
5014
  "slug": "help/rows/transactions",
@@ -5105,7 +5120,7 @@ export const docs = [
5105
5120
  "path": "docs/help/server/server-row-model.md",
5106
5121
  "title": "Server-Side Row Model (SSRM)",
5107
5122
  "section": "Server data",
5108
- "markdown": "# Server-Side Row Model (SSRM)\n\nWhen the data lives on the server - millions of rows in a database - the grid\nshould hold only the page on screen and push sorting, filtering, and paging to\nthe backend. SvGrid packages this as **one datasource contract**: you implement\na single async `getRows`, and `createServerDataSource` owns the request\nlifecycle (paging, sort, filter, race-safety, writes).\n\nNew to the model? [Server-Side Row Model: a walkthrough](./row-model-walkthrough.md) builds it up one step at a time against a server written on the page; this page is the contract in full.\n\n![The createServerDataSource request lifecycle: setSort, setFilter, and setPage trigger a fetch that tags each getRows call with a monotonic request id, and only the latest request lands as a ServerState of rows, total, loading, saving, and error that the grid renders from.](/docs-media/server-controller-state.svg)\n\n<div data-docs-demo=\"148-server-row-model\" data-height=\"480\"></div>\n\n> **Scope.** `createServerDataSource` is the free, flat row model: one page at\n> a time in `page` mode, or one long block-cached list in\n> [`infinite` mode](./server-infinite-scroll.md). Grouping, tree data, pivot,\n> transactions and selection across unloaded rows are the Enterprise\n> [Server-Side Row Model](./server-grouping.md), built on the same contract.\n> See [what this does not do](#what-this-does-not-do).\n\n## Quick start\n\n```ts\nimport { createServerDataSource, type ServerDataSource } from '@svgrid/grid'\n\nconst source: ServerDataSource<Row> = {\n async getRows({ startRow, endRow, sortModel, filterModel }) {\n const res = await fetch('/api/rows', {\n method: 'POST',\n body: JSON.stringify({ startRow, endRow, sortModel, filterModel }),\n })\n const { rows, total } = await res.json()\n return { rows, rowCount: total } // rowCount = total AFTER filtering\n },\n}\n\nlet view = $state(/* ServerState */)\nconst ctl = createServerDataSource(source, { pageSize: 50, onChange: (s) => (view = s) })\nctl.refresh()\n```\n\n## The request your server receives\n\nEvery fetch calls `getRows(request)` with this exact shape:\n\n```ts\ntype ServerRequest = {\n startRow: number // first row wanted, inclusive (= pageIndex * pageSize)\n endRow: number // one past the last row wanted, exclusive\n pageIndex: number\n pageSize: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n}\n```\n\n`startRow` / `endRow` are the **page** bounds (they map to `OFFSET` / `LIMIT`),\nnot block-cache bounds. Return `{ rows, rowCount }` where `rowCount` is the total\ncount **after filtering** - the pager needs it to compute the last page.\n\nEvery request also carries `signal`, an `AbortSignal` the grid aborts when it\nno longer wants the answer: a newer page superseded this one, a block was\npurged or scrolled out of the cache, a group was collapsed, the controller\nwas disposed. Hand it to `fetch(url, { signal })` and a query the user has\nscrolled past is cancelled at the server instead of completing for nothing;\nthe grid ignores the rejection the abort produces. The SvelteKit transport\ndoes this for you and keeps the signal out of the posted body.\n\n### The sort model\n\n```ts\ntype ServerSortModel = Array<{ id: string; desc: boolean }>\n```\n\nMulti-column, in priority order. So this:\n\n```json\n[{ \"id\": \"lastName\", \"desc\": false }, { \"id\": \"age\", \"desc\": true }]\n```\n\nmaps to `ORDER BY last_name ASC, age DESC`.\n\n### The filter model\n\n```ts\ntype ServerFilterModel = {\n global?: string // the quick-filter search box\n columns?: Record<string, { // keyed by column id\n operator: string // equals | contains | startsWith | greaterThan | lessThan | between | isBlank\n value: string\n valueTo?: string // second bound, for `between`\n selectedValues?: string[] // set-filter (facet checklist) selection\n }>\n}\n```\n\nA populated model looks like:\n\n```json\n{\n \"global\": \"berlin\",\n \"columns\": {\n \"status\": { \"operator\": \"equals\", \"value\": \"active\" },\n \"age\": { \"operator\": \"between\", \"value\": \"18\", \"valueTo\": \"65\" },\n \"country\": { \"operator\": \"contains\", \"value\": \"\", \"selectedValues\": [\"DE\", \"FR\"] }\n }\n}\n```\n\n## Translating the model to SQL\n\nMap each column filter's `operator` to a predicate:\n\n| `operator` | SQL |\n| -------------- | -------------------------------- |\n| `equals` | `col = $value` |\n| `contains` | `col ILIKE '%' || $value || '%'` |\n| `startsWith` | `col ILIKE $value || '%'` |\n| `greaterThan` | `col > $value` |\n| `lessThan` | `col < $value` |\n| `between` | `col BETWEEN $value AND $valueTo`|\n| `isBlank` | `col IS NULL OR col = ''` |\n| `selectedValues` present | `col IN ($selectedValues)` |\n\n`global` is a free-text search you `OR` across your searchable columns. Always\nbind values as parameters - never string-concatenate them into SQL.\n\nYou do not have to hand-write that switch. `@svgrid/enterprise` ships\n`normalizeFilters(filterModel)`, which returns `{ predicates, search }` with the\noperators already normalized to `in` / `contains` / `startsWith` / `eq` / `gt` /\n`lt` / `between` - the same helper the built-in REST and SQL sources use. For a\ncomplete, runnable server (route + query builder), follow the\n[Postgres CRUD tutorial](../../enterprise/studio/postgres-grid.md).\n\n## Wiring to the grid\n\nThe controller is a **row model**: hand it to the grid and every seam is\nwired - the rows, the loading flag, external sort and filter, the pager in\npage mode, the visible range and the placeholder rows in infinite mode:\n\n```svelte\n<SvGrid rowModel={ctl} {columns} {features} sortable filterable pageable />\n```\n\nA prop written on the grid beats what the model supplies, so you can adopt\nthe model and still override one piece. The long form spells out the same\nseams by hand, which is also how a model of your own plugs in:\n\n```svelte\n<SvGrid\n data={view.rows}\n {columns} {features}\n sortable filterable\n externalSort externalFilter\n loading={view.loading}\n externalPagination rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\n onSortingChange={(sorting) => ctl.setSort(sorting)}\n onFiltersChange={(f) => ctl.setFilter(f)}\n onPaginationChange={(p) => (p.pageSize !== view.pageSize ? ctl.setPageSize(p.pageSize) : ctl.setPage(p.pageIndex))}\n/>\n```\n\n`externalSort` / `externalFilter` tell the grid to emit intent instead of acting\nlocally; `externalPagination` hands the pager the server's count.\n\n## Infinite mode\n\nPass `mode: 'infinite'` and there are no pages: `state.rows` spans the whole\nresult, placeholder rows stand in for blocks nobody has scrolled to, and\nblocks of `blockSize` rows load as the viewport reaches them, with an LRU\ncap, a concurrency cap, retry for failed blocks and an unknown-count mode.\n[Server-side infinite scroll](./server-infinite-scroll.md) covers it.\n\n## The controller\n\n`createServerDataSource(source, options)` returns:\n\n| Method | Does |\n| ------------------- | -------------------------------------------------------- |\n| `refresh()` | Re-fetch the current page (after a mutation). |\n| `setSort(model)` | New sort, jump to page 0, fetch. |\n| `setFilter(model)` | New filter, jump to page 0, fetch. |\n| `setPage(i)` | Fetch page `i`. |\n| `setPageSize(n)` | New page size, page 0, fetch. |\n| `createRow(input)` | Create through the source, then refresh. Throws if the source has no `createRow`. |\n| `updateRow(id, p)` | Update by id. Optimistic when configured (below). |\n| `deleteRow(id)` | Delete by id. Optimistic when configured. |\n| `getState()` | Snapshot of the current `ServerState`. |\n| `dispose()` | Stop accepting in-flight responses. Call on unmount. |\n\n## The state you render from\n\n`onChange` fires on every transition. The full shape:\n\n```ts\ntype ServerState<Row> = {\n rows: ReadonlyArray<Row>\n total: number // total after filtering (drives the pager)\n loading: boolean // a getRows fetch is in flight\n saving: boolean // a create / update / delete is in flight\n error: unknown // the rejection from the last failed getRows (else null)\n pageIndex: number\n pageSize: number\n pageCount: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n}\n```\n\nBind `loading` to the grid's overlay, `saving` to a toolbar spinner, and check\n`error` to render a retry banner.\n\n## Writes and optimistic updates\n\nImplement whichever of `createRow` / `updateRow` / `deleteRow` your backend\nsupports; the matching controller method calls through and then refreshes the\ncurrent page. Calling one whose source counterpart is missing throws a clear\nerror, so a read-only source stays read-only.\n\nBy default writes are **non-optimistic**: the grid reflects the change only\nafter the follow-up refresh lands. For instant feedback, opt in:\n\n```ts\nconst ctl = createServerDataSource(source, {\n pageSize: 50,\n onChange: (s) => (view = s),\n optimistic: true,\n getRowId: (r) => String(r.id), // required, so a row can be located in the page\n})\n```\n\nNow `updateRow` patches the local row immediately and reconciles with the server\nresult (rolling back on error); `deleteRow` removes it and decrements `total`,\nrestoring both if the server rejects.\n\n## Error handling\n\nIf `getRows` rejects, the controller clears `rows`, stores the rejection in\n`state.error`, and drops `loading` - it never throws into your render. Recover\nby re-fetching:\n\n```svelte\n{#if view.error}\n <div role=\"alert\">\n Could not load rows. <button onclick={() => ctl.refresh()}>Retry</button>\n </div>\n{/if}\n```\n\nA successful fetch clears `error` again.\n\n## Race safety\n\nEvery fetch carries a monotonic id; only the **latest** request is allowed to\nland. A slow response for an old sort/filter can never clobber a newer one - the\nclassic SSRM bug, handled for you. `dispose()` drops everything in flight and\nclears `loading`, so an unmounting component can't leave a stuck spinner.\n\n## What this does not do\n\nBeing honest about the edges so you pick the right tool:\n\n- **Flat rows only.** `createServerDataSource` sends `groupBy: []`. Lazy\n grouping per level, tree data and pivot are `createServerRowModel` in\n `@svgrid/enterprise` - the same `getRows`, with `groupBy`, `groupKeys`,\n `aggregations` and `pivotBy` filled in. See [Server grouping](./server-grouping.md).\n- **A selection is the loaded rows.** Select-all over rows the grid never\n fetched, and a bulk edit sent as a rule, are the Enterprise model's\n [selection](./server-selection.md).\n- **Writes refetch.** A write re-reads the page (or the held blocks); rows are\n not spliced in by hand. Route-addressed [transactions](./server-transactions.md)\n are Enterprise.\n- **Writes are non-optimistic unless you opt in** (above).\n\n## Enterprise: the Server-Side Row Model\n\n`createServerRowModel` from `@svgrid/enterprise` is the same contract with\nthe rest of the request filled in, and the same one-prop wiring:\n\n| Page | What it adds |\n| ---- | ------------ |\n| [Server grouping](./server-grouping.md) | lazy grouping per level, a block cache per level, aggregates, child counts, grand totals, footers, refresh and retry per route, paging over the tree |\n| [Server tree data](./server-tree-data.md) | self-referential trees loaded on expand |\n| [Server pivot](./server-pivot.md) | pivot on the server, columns built from `pivotResultFields`, the pivot designer in server mode |\n| [Server transactions](./server-transactions.md) | add, update and remove rows in a loaded level without a request, sync or batched |\n| [Server selection](./server-selection.md) | select-all as a rule across unloaded rows, a bulk edit by rule |\n| [Worker data source](./worker-data-source.md) | rows already in the browser, queried in a Web Worker so sorting and grouping do not block the page |\n\nThe flagship demo runs all of it over one million rows through one\n`rowModel` prop:\n\n<div data-docs-demo=\"467-server-row-model-1m\" data-height=\"640\"></div>\n\n### A backend written for a callback-style datasource\n\nSome server-side row models hand the datasource a `getRows(params)` that\nanswers through `params.success({ rowData, rowCount })`, with the request\nspelt `rowGroupCols` / `valueCols` / `pivotCols` and a `sortModel` of\n`{ colId, sort }`. `@svgrid/enterprise` maps that shape both ways, so a\nbackend built for it needs no rewrite:\n\n- `adaptCallbackDatasource(ds)` wraps a datasource object of that shape\n for `createServerRowModel` or `createServerDataSource`.\n- `toCallbackRequest(request)` turns a `ServerRequest` into that request\n JSON, for a `getRows` that is a `fetch` against an endpoint which already\n parses it; `fromCallbackRequest` reads it, for an endpoint of yours that\n has to serve such a client too.\n- `toCallbackSelectionState` / `fromCallbackSelectionState` do the same for\n a saved selection rule (`toggledNodes` instead of `toggled`); see\n [Server selection](./server-selection.md).\n\n## More examples\n\n### Server-side pivot\n\nThe pivot designer in server mode over a million rows: Rows become groupBy, Columns pivotBy, Values aggregations, and every applied layout is one request.\n\n<div data-docs-demo=\"468-server-pivot\" data-height=\"560\"></div>\n\n### Server tree data (row model)\n\nA file tree the grid never holds whole: expanding a folder is one getRows with the folder path as groupKeys, per-folder refresh, and a file added or deleted without a refetch.\n\n<div data-docs-demo=\"469-server-tree-data\" data-height=\"560\"></div>\n\n### Server transactions (live feed)\n\nChanges the server already made, applied without a refetch: in-place patches with a flash, batched add and remove transactions addressed by route, and a status for every result.\n\n<div data-docs-demo=\"470-server-transactions\" data-height=\"560\"></div>\n\n### Server selection: select all, minus these\n\nSelect-all as a rule the server counts, flat and per group, with a bulk edit the server applies by rule.\n\n<div data-docs-demo=\"471-server-selection\" data-height=\"560\"></div>\n\n### Server row model to SQL\n\nThe statements planToSql renders for every request, per dialect: rows, count, grand total and the two-step pivot.\n\n<div data-docs-demo=\"472-server-sql-planner\" data-height=\"560\"></div>\n\n### Server grouping: totals, sort and refresh rules\n\nGrand total positions, subtotal footers, open-by-default levels, expand-all over unloaded groups, refresh against purge, and what a sort or a filter re-requests, with the request log.\n\n<div data-docs-demo=\"473-server-grouping-rules\" data-height=\"560\"></div>\n\n### Server row model: CRUD\n\nThe four writes against a backend that says no: a form under the focused region, inline edits with a version check, a refused delete, an undo, `saving`, and optimistic mode against a slow server.\n\n<div data-docs-demo=\"482-server-crud\" data-height=\"560\"></div>\n\n### Server row model: master-detail\n\nA detail panel under any order of the grouped, virtualized tree, fetched from its own endpoint when it opens, with the region row held under the header.\n\n<div data-docs-demo=\"483-server-master-detail\" data-height=\"560\"></div>\n\n### Server grouping (row model)\n\nServer-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerRowModel owns the group tree - a block cache per level, lazy expand, per-group sums and a subtotal footer, race-safety - mounted through the one rowModel prop. Leaves arrive by scroll, behind a Load N more row, or paged across the whole tree, and the group panel regroups on the fly. Here a 63,000-row in-memory server behind 200ms latency; the grid holds only the groups you expand. The row model ships in @svgrid/enterprise.\n\n<div data-docs-demo=\"496-server-grouping-model\" data-height=\"560\"></div>\n\n### Server-side infinite scroll\n\n100k-event audit log behind a mock API. Sparse chunked load on scroll; sort + filter + search pushed to the server.\n\n<div data-docs-demo=\"33-server-infinite\" data-height=\"560\"></div>\n\n### Server-side grouping + aggregates\n\nGROUP BY + SUM/AVG pushed to the server; pre-aggregated buckets with on-demand drill-in.\n\n<div data-docs-demo=\"114-server-grouping\" data-height=\"560\"></div>\n\n### Server-side data\n\nSort/filter/page round-tripped to a mock endpoint with debounce + cancel.\n\n<div data-docs-demo=\"09-server-side\" data-height=\"560\"></div>\n\n### Server-side rendering\n\nSvelteKit-style SSR with a sandboxed pre-hydration snapshot.\n\n<div data-docs-demo=\"19-ssr\" data-height=\"460\"></div>\n\n## See also\n\n- [Server-side infinite scroll](./server-infinite-scroll.md) - `mode: 'infinite'`: blocks, placeholders, retry, unknown counts.\n- [Tutorial: a Postgres CRUD grid](../../enterprise/studio/postgres-grid.md) - a complete server route + query builder.\n- [Tutorial: a REST CRUD grid](../../enterprise/studio/rest-grid.md) - the same contract over an existing JSON API.\n- [Data binding](../../enterprise/studio/data-binding.md) - the ServerDataSource contract across every backend.\n- [Server-side data](../server-side-data.md) - the wider server-mode overview.\n"
5123
+ "markdown": "# Server-Side Row Model (SSRM)\n\nWhen the data lives on the server - millions of rows in a database - the grid\nshould hold only the page on screen and push sorting, filtering, and paging to\nthe backend. SvGrid packages this as **one datasource contract**: you implement\na single async `getRows`, and `createServerDataSource` owns the request\nlifecycle (paging, sort, filter, race-safety, writes).\n\nNew to the model? [Server-Side Row Model: a walkthrough](./row-model-walkthrough.md) builds it up one step at a time against a server written on the page; this page is the contract in full.\n\n![The createServerDataSource request lifecycle: setSort, setFilter, and setPage trigger a fetch that tags each getRows call with a monotonic request id, and only the latest request lands as a ServerState of rows, total, loading, saving, and error that the grid renders from.](/docs-media/server-controller-state.svg)\n\n<div data-docs-demo=\"148-server-row-model\" data-height=\"480\"></div>\n\n> **Scope.** `createServerDataSource` is the free, flat row model: one page at\n> a time in `page` mode, or one long block-cached list in\n> [`infinite` mode](./server-infinite-scroll.md). Grouping, tree data, pivot,\n> transactions and selection across unloaded rows are the Enterprise\n> [Server-Side Row Model](./server-grouping.md), built on the same contract.\n> See [what this does not do](#what-this-does-not-do).\n\n## Quick start\n\n```ts\nimport { createServerDataSource, type ServerDataSource } from '@svgrid/grid'\n\nconst source: ServerDataSource<Row> = {\n async getRows({ startRow, endRow, sortModel, filterModel }) {\n const res = await fetch('/api/rows', {\n method: 'POST',\n body: JSON.stringify({ startRow, endRow, sortModel, filterModel }),\n })\n const { rows, total } = await res.json()\n return { rows, rowCount: total } // rowCount = total AFTER filtering\n },\n}\n\nlet view = $state(/* ServerState */)\nconst ctl = createServerDataSource(source, { pageSize: 50, onChange: (s) => (view = s) })\nctl.refresh()\n```\n\n## The request your server receives\n\nEvery fetch calls `getRows(request)` with this exact shape:\n\n```ts\ntype ServerRequest = {\n startRow: number // first row wanted, inclusive (= pageIndex * pageSize)\n endRow: number // one past the last row wanted, exclusive\n pageIndex: number\n pageSize: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n}\n```\n\n`startRow` / `endRow` are the **page** bounds (they map to `OFFSET` / `LIMIT`),\nnot block-cache bounds. Return `{ rows, rowCount }` where `rowCount` is the total\ncount **after filtering** - the pager needs it to compute the last page.\n\nEvery request also carries `signal`, an `AbortSignal` the grid aborts when it\nno longer wants the answer: a newer page superseded this one, a block was\npurged or scrolled out of the cache, a group was collapsed, the controller\nwas disposed. Hand it to `fetch(url, { signal })` and a query the user has\nscrolled past is cancelled at the server instead of completing for nothing;\nthe grid ignores the rejection the abort produces. The SvelteKit transport\ndoes this for you and keeps the signal out of the posted body.\n\n### The sort model\n\n```ts\ntype ServerSortModel = Array<{ id: string; desc: boolean }>\n```\n\nMulti-column, in priority order. So this:\n\n```json\n[{ \"id\": \"lastName\", \"desc\": false }, { \"id\": \"age\", \"desc\": true }]\n```\n\nmaps to `ORDER BY last_name ASC, age DESC`.\n\n### The filter model\n\n```ts\ntype ServerFilterModel = {\n global?: string // the quick-filter search box\n columns?: Record<string, { // keyed by column id\n operator: string // equals | contains | startsWith | greaterThan | lessThan | between | isBlank\n value: string\n valueTo?: string // second bound, for `between`\n selectedValues?: string[] // set-filter (facet checklist) selection\n }>\n}\n```\n\nA populated model looks like:\n\n```json\n{\n \"global\": \"berlin\",\n \"columns\": {\n \"status\": { \"operator\": \"equals\", \"value\": \"active\" },\n \"age\": { \"operator\": \"between\", \"value\": \"18\", \"valueTo\": \"65\" },\n \"country\": { \"operator\": \"contains\", \"value\": \"\", \"selectedValues\": [\"DE\", \"FR\"] }\n }\n}\n```\n\n## Translating the model to SQL\n\nMap each column filter's `operator` to a predicate:\n\n| `operator` | SQL |\n| -------------- | -------------------------------- |\n| `equals` | `col = $value` |\n| `contains` | `col ILIKE '%' || $value || '%'` |\n| `startsWith` | `col ILIKE $value || '%'` |\n| `greaterThan` | `col > $value` |\n| `lessThan` | `col < $value` |\n| `between` | `col BETWEEN $value AND $valueTo`|\n| `isBlank` | `col IS NULL OR col = ''` |\n| `selectedValues` present | `col IN ($selectedValues)` |\n\n`global` is a free-text search you `OR` across your searchable columns. Always\nbind values as parameters - never string-concatenate them into SQL.\n\nYou do not have to hand-write that switch. `@svgrid/enterprise` ships\n`normalizeFilters(filterModel)`, which returns `{ predicates, search }` with the\noperators already normalized to `in` / `contains` / `startsWith` / `eq` / `gt` /\n`lt` / `between` - the same helper the built-in REST and SQL sources use. For a\ncomplete, runnable server (route + query builder), follow the\n[Postgres CRUD tutorial](../../enterprise/studio/postgres-grid.md).\n\n## Wiring to the grid\n\nThe controller is a **row model**: hand it to the grid and every seam is\nwired - the rows, the loading flag, external sort and filter, the pager in\npage mode, the visible range and the placeholder rows in infinite mode:\n\n```svelte\n<SvGrid rowModel={ctl} {columns} {features} sortable filterable pageable />\n```\n\nA prop written on the grid beats what the model supplies, so you can adopt\nthe model and still override one piece. The long form spells out the same\nseams by hand, which is also how a model of your own plugs in:\n\n```svelte\n<SvGrid\n data={view.rows}\n {columns} {features}\n sortable filterable\n externalSort externalFilter\n loading={view.loading}\n externalPagination rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\n onSortingChange={(sorting) => ctl.setSort(sorting)}\n onFiltersChange={(f) => ctl.setFilter(f)}\n onPaginationChange={(p) => (p.pageSize !== view.pageSize ? ctl.setPageSize(p.pageSize) : ctl.setPage(p.pageIndex))}\n/>\n```\n\n`externalSort` / `externalFilter` tell the grid to emit intent instead of acting\nlocally; `externalPagination` hands the pager the server's count.\n\n## Infinite mode\n\nPass `mode: 'infinite'` and there are no pages: `state.rows` spans the whole\nresult, placeholder rows stand in for blocks nobody has scrolled to, and\nblocks of `blockSize` rows load as the viewport reaches them, with an LRU\ncap, a concurrency cap, retry for failed blocks and an unknown-count mode.\n[Server-side infinite scroll](./server-infinite-scroll.md) covers it.\n\n## The controller\n\n`createServerDataSource(source, options)` returns:\n\n| Method | Does |\n| ------------------- | -------------------------------------------------------- |\n| `refresh()` | Re-fetch the current page (after a mutation). |\n| `setSort(model)` | New sort, jump to page 0, fetch. |\n| `setFilter(model)` | New filter, jump to page 0, fetch. |\n| `setPage(i)` | Fetch page `i`. |\n| `setPageSize(n)` | New page size, page 0, fetch. |\n| `createRow(input)` | Create through the source, then refresh. Throws if the source has no `createRow`. |\n| `updateRow(id, p)` | Update by id. Optimistic when configured (below). |\n| `deleteRow(id)` | Delete by id. Optimistic when configured. |\n| `getState()` | Snapshot of the current `ServerState`. |\n| `dispose()` | Stop accepting in-flight responses. Call on unmount. |\n\n## The state you render from\n\n`onChange` fires on every transition. The full shape:\n\n```ts\ntype ServerState<Row> = {\n rows: ReadonlyArray<Row>\n total: number // total after filtering (drives the pager)\n loading: boolean // a getRows fetch is in flight\n saving: boolean // a create / update / delete is in flight\n error: unknown // the rejection from the last failed getRows (else null)\n pageIndex: number\n pageSize: number\n pageCount: number\n sortModel: ServerSortModel\n filterModel: ServerFilterModel\n}\n```\n\nBind `loading` to the grid's overlay, `saving` to a toolbar spinner, and check\n`error` to render a retry banner.\n\n## Writes and optimistic updates\n\nImplement whichever of `createRow` / `updateRow` / `deleteRow` your backend\nsupports; the matching controller method calls through and then refreshes the\ncurrent page. Calling one whose source counterpart is missing throws a clear\nerror, so a read-only source stays read-only.\n\nBy default writes are **non-optimistic**: the grid reflects the change only\nafter the follow-up refresh lands. For instant feedback, opt in:\n\n```ts\nconst ctl = createServerDataSource(source, {\n pageSize: 50,\n onChange: (s) => (view = s),\n optimistic: true,\n getRowId: (r) => String(r.id), // required, so a row can be located in the page\n})\n```\n\nNow `updateRow` patches the local row immediately and reconciles with the server\nresult (rolling back on error); `deleteRow` removes it and decrements `total`,\nrestoring both if the server rejects.\n\n## Error handling\n\nIf `getRows` rejects, the controller clears `rows`, stores the rejection in\n`state.error`, and drops `loading` - it never throws into your render. Recover\nby re-fetching:\n\n```svelte\n{#if view.error}\n <div role=\"alert\">\n Could not load rows. <button onclick={() => ctl.refresh()}>Retry</button>\n </div>\n{/if}\n```\n\nA successful fetch clears `error` again.\n\n## Race safety\n\nEvery fetch carries a monotonic id; only the **latest** request is allowed to\nland. A slow response for an old sort/filter can never clobber a newer one - the\nclassic SSRM bug, handled for you. `dispose()` drops everything in flight and\nclears `loading`, so an unmounting component can't leave a stuck spinner.\n\n## What this does not do\n\nBeing honest about the edges so you pick the right tool:\n\n- **Flat rows only.** `createServerDataSource` sends `groupBy: []`. Lazy\n grouping per level, tree data and pivot are `createServerRowModel` in\n `@svgrid/enterprise` - the same `getRows`, with `groupBy`, `groupKeys`,\n `aggregations` and `pivotBy` filled in. See [Server grouping](./server-grouping.md).\n- **A selection is the loaded rows.** Select-all over rows the grid never\n fetched, and a bulk edit sent as a rule, are the Enterprise model's\n [selection](./server-selection.md).\n- **Writes refetch.** A write re-reads the page (or the held blocks); rows are\n not spliced in by hand. Route-addressed [transactions](./server-transactions.md)\n are Enterprise.\n- **Writes are non-optimistic unless you opt in** (above).\n\n## Enterprise: the Server-Side Row Model\n\n`createServerRowModel` from `@svgrid/enterprise` is the same contract with\nthe rest of the request filled in, and the same one-prop wiring:\n\n| Page | What it adds |\n| ---- | ------------ |\n| [Server grouping](./server-grouping.md) | lazy grouping per level, a block cache per level, aggregates, child counts, grand totals, footers, refresh and retry per route, paging over the tree |\n| [Server tree data](./server-tree-data.md) | self-referential trees loaded on expand |\n| [Server pivot](./server-pivot.md) | pivot on the server, columns built from `pivotResultFields`, the pivot designer in server mode |\n| [Server transactions](./server-transactions.md) | add, update and remove rows in a loaded level without a request, sync or batched |\n| [Server selection](./server-selection.md) | select-all as a rule across unloaded rows, a bulk edit by rule |\n| [Worker data source](./worker-data-source.md) | rows already in the browser, queried in a Web Worker so sorting and grouping do not block the page |\n\nThe flagship demo runs all of it over one million rows through one\n`rowModel` prop:\n\n<div data-docs-demo=\"467-server-row-model-1m\" data-height=\"640\"></div>\n\n### A backend written for a callback-style datasource\n\nSome server-side row models hand the datasource a `getRows(params)` that\nanswers through `params.success({ rowData, rowCount })`, with the request\nspelt `rowGroupCols` / `valueCols` / `pivotCols` and a `sortModel` of\n`{ colId, sort }`. `@svgrid/enterprise` maps that shape both ways, so a\nbackend built for it needs no rewrite:\n\n- `adaptCallbackDatasource(ds)` wraps a datasource object of that shape\n for `createServerRowModel` or `createServerDataSource`.\n- `toCallbackRequest(request)` turns a `ServerRequest` into that request\n JSON, for a `getRows` that is a `fetch` against an endpoint which already\n parses it; `fromCallbackRequest` reads it, for an endpoint of yours that\n has to serve such a client too.\n- `toCallbackSelectionState` / `fromCallbackSelectionState` do the same for\n a saved selection rule (`toggledNodes` instead of `toggled`); see\n [Server selection](./server-selection.md).\n\n## More examples\n\n### Big data: 10M rows x 10K columns\n\nUp to 10,000,000 rows and 10,000 columns in one SvGrid on the Enterprise server row model. A Web Worker sorts (radix, exact), filters, groups and edits a generated dataset and sends back only the rows on screen; the grid builds just those, so its memory and scroll cost stay flat as the row count grows. Pick a size, sort, filter, group, edit a cell, or run the benchmark: every number on the page is measured live in your browser.\n\n<div data-docs-demo=\"503-big-data\" data-height=\"560\"></div>\n\n### Server-side pivot\n\nThe pivot designer in server mode over a million rows: Rows become groupBy, Columns pivotBy, Values aggregations, and every applied layout is one request.\n\n<div data-docs-demo=\"468-server-pivot\" data-height=\"560\"></div>\n\n### Server tree data (row model)\n\nA file tree the grid never holds whole: expanding a folder is one getRows with the folder path as groupKeys, per-folder refresh, and a file added or deleted without a refetch.\n\n<div data-docs-demo=\"469-server-tree-data\" data-height=\"560\"></div>\n\n### Server transactions (live feed)\n\nChanges the server already made, applied without a refetch: in-place patches with a flash, batched add and remove transactions addressed by route, and a status for every result.\n\n<div data-docs-demo=\"470-server-transactions\" data-height=\"560\"></div>\n\n### Server selection: select all, minus these\n\nSelect-all as a rule the server counts, flat and per group, with a bulk edit the server applies by rule.\n\n<div data-docs-demo=\"471-server-selection\" data-height=\"560\"></div>\n\n### Server row model to SQL\n\nThe statements planToSql renders for every request, per dialect: rows, count, grand total and the two-step pivot.\n\n<div data-docs-demo=\"472-server-sql-planner\" data-height=\"560\"></div>\n\n### Server grouping: totals, sort and refresh rules\n\nGrand total positions, subtotal footers, open-by-default levels, expand-all over unloaded groups, refresh against purge, and what a sort or a filter re-requests, with the request log.\n\n<div data-docs-demo=\"473-server-grouping-rules\" data-height=\"560\"></div>\n\n### Server row model: CRUD\n\nThe four writes against a backend that says no: a form under the focused region, inline edits with a version check, a refused delete, an undo, `saving`, and optimistic mode against a slow server.\n\n<div data-docs-demo=\"482-server-crud\" data-height=\"560\"></div>\n\n### Server row model: master-detail\n\nA detail panel under any order of the grouped, virtualized tree, fetched from its own endpoint when it opens, with the region row held under the header.\n\n<div data-docs-demo=\"483-server-master-detail\" data-height=\"560\"></div>\n\n### Server grouping (row model)\n\nServer-side grouping through one getRows contract: the request carries groupBy + groupKeys, and createServerRowModel owns the group tree - a block cache per level, lazy expand, per-group sums and a subtotal footer, race-safety - mounted through the one rowModel prop. Leaves arrive by scroll, behind a Load N more row, or paged across the whole tree, and the group panel regroups on the fly. Here a 63,000-row in-memory server behind 200ms latency; the grid holds only the groups you expand. The row model ships in @svgrid/enterprise.\n\n<div data-docs-demo=\"496-server-grouping-model\" data-height=\"560\"></div>\n\n### Server-side infinite scroll\n\n100k-event audit log behind a mock API. Sparse chunked load on scroll; sort + filter + search pushed to the server.\n\n<div data-docs-demo=\"33-server-infinite\" data-height=\"560\"></div>\n\n### Server-side grouping + aggregates\n\nGROUP BY + SUM/AVG pushed to the server; pre-aggregated buckets with on-demand drill-in.\n\n<div data-docs-demo=\"114-server-grouping\" data-height=\"560\"></div>\n\n### Server-side data\n\nSort/filter/page round-tripped to a mock endpoint with debounce + cancel.\n\n<div data-docs-demo=\"09-server-side\" data-height=\"560\"></div>\n\n### Server-side rendering\n\nSvelteKit-style SSR with a sandboxed pre-hydration snapshot.\n\n<div data-docs-demo=\"19-ssr\" data-height=\"460\"></div>\n\n## See also\n\n- [Server-side infinite scroll](./server-infinite-scroll.md) - `mode: 'infinite'`: blocks, placeholders, retry, unknown counts.\n- [Tutorial: a Postgres CRUD grid](../../enterprise/studio/postgres-grid.md) - a complete server route + query builder.\n- [Tutorial: a REST CRUD grid](../../enterprise/studio/rest-grid.md) - the same contract over an existing JSON API.\n- [Data binding](../../enterprise/studio/data-binding.md) - the ServerDataSource contract across every backend.\n- [Server-side data](../server-side-data.md) - the wider server-mode overview.\n"
5109
5124
  },
5110
5125
  {
5111
5126
  "slug": "help/server/server-selection",
@@ -5273,7 +5288,7 @@ export const docs = [
5273
5288
  "path": "docs/help/tokens.md",
5274
5289
  "title": "Design tokens",
5275
5290
  "section": "Help",
5276
- "markdown": "# Design tokens\n\nEvery visual property the grid exposes for theming is a CSS custom\nproperty (`--sg-*`). Tokens cascade like any other CSS variable -\noverride at `:root`, on a wrapper element, or per-grid; the closest\ndeclaration wins.\n<div data-docs-demo=\"74-theme-integrations\" data-height=\"540\"></div>\n\n> No build step. No theme provider. No design-system lock-in. Set a\n> few `--sg-*` tokens and the grid re-skins immediately.\n\nLive in [demo 74 (Theme integrations)](https://svgrid.com/demos/74-theme-integrations/)\n- shows the same grid rendered through Ant Design, MUI, Fluent, Base\nWeb, and shadcn token sets.\n\n## Core surface tokens\n\nThese are the tokens 95% of integrations override. Stable; safe to\nship in a corporate design system.\n\n| Token | Default (light) | Default (dark) | What it paints |\n| ---------------------- | ---------------------- | ---------------------- | -------------------------------------------------------------------- |\n| `--sg-bg` | `#ffffff` | `#0f172a` | Grid background / cell fill |\n| `--sg-fg` | `#0f172a` | `#f1f5f9` | Primary text colour |\n| `--sg-muted` | `#64748b` | `#94a3b8` | Secondary text (header subtitles, placeholders, \"no data\" message) |\n| `--sg-border` | `#e2e8f0` | `#1e293b` | Cell borders, separators |\n| `--sg-header-bg` | `#f8fafc` | `#1e293b` | Column-header row background |\n| `--sg-header-fg` | inherits `--sg-fg` | inherits `--sg-fg` | Column-header text |\n| `--sg-header-border` | inherits `--sg-border` | inherits `--sg-border` | Line under the column-header row only (body cell borders unaffected) |\n| `--sg-row-alt-bg` | inherits `--sg-bg` | inherits `--sg-bg` | Zebra-stripe background for even-indexed rows |\n| `--sg-row-hover-bg` | `rgba(148,163,184,.10)`| `rgba(148,163,184,.14)`| Hover row background |\n| `--sg-selection-bg` | `rgba(99,102,241,.10)` | `rgba(99,102,241,.18)` | Selected row / selected cell-range fill |\n| `--sg-accent` | `#6366f1` | `#818cf8` | Active-cell ring, primary buttons, sort indicator |\n| `--sg-focus-ring` | `0 0 0 2px rgba(99,102,241,.40)` | same | Box-shadow used for the focus outline |\n\n## Chart series tokens\n\n| Token | Default | What it controls |\n| ------------------------ | --------- | ----------------------------------------- |\n| `--sg-chart-1` | `#2563eb` | First series colour |\n| `--sg-chart-2` | `#16a34a` | Second series |\n| `--sg-chart-3` | `#f59e0b` | Third series |\n| `--sg-chart-4` | `#ef4444` | Fourth series |\n| `--sg-chart-5` | `#8b5cf6` | Fifth series |\n| `--sg-chart-6` | `#0ea5e9` | Sixth series |\n| `--sg-chart-7` | `#ec4899` | Seventh series |\n| `--sg-chart-8` | `#14b8a6` | Eighth series |\n\nThe chart cycles through these in order, so a ninth series reuses the first.\nThey also colour the marks that have no series of their own, such as pie\nslices and tree-map cells.\n\nSet as few as you like; the rest keep their defaults, so a brand primary and\nsecondary is often the whole change:\n\n```css\n:root {\n --sg-chart-1: #7c3aed;\n --sg-chart-2: #db2777;\n}\n```\n\nThe tokens are read where the chart is mounted, so a wrapper can recolour one\nchart without touching the page:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvChart, type ChartSpec } from '@svgrid/grid'\n const spec: ChartSpec = {\n type: 'bar',\n categories: ['Q1', 'Q2', 'Q3', 'Q4'],\n series: [{ label: 'Plan', values: [40, 45, 50, 55] }, { label: 'Actual', values: [38, 49, 47, 61] }, { label: 'Stretch', values: [45, 50, 56, 62] }],\n height: 220,\n }\n</script>\n\n<div style=\"--sg-chart-1: #7c3aed; --sg-chart-2: #db2777; --sg-chart-3: #0d9488\">\n <SvChart {spec} />\n</div>\n```\n\nA spec's own `palette` beats the tokens, and a per-series `color` beats\nboth. The type gallery draws all twenty-nine types from the same eight\ntokens:\n\n<div data-docs-demo=\"435-chart-type-gallery\" data-height=\"600\"></div>\n\nPrecedence runs explicit-to-general: a `color` on the series wins, then\n`spec.palette` (or `charting.palette`), then these tokens, then the built-in\ncolours. The first two are the author speaking about one chart; the tokens are\nthe theme speaking about all of them.\n\n## Layout tokens\n\n| Token | Default | What it controls |\n| ---------------------- | ----------- | ---------------------------------------------------------------------- |\n| `--sg-radius` | `6px` | Border radius on rounded UI inside the grid (chips, badges, menus) |\n| `--sg-font` | inherits | Font family used inside the grid |\n\nRow height is **not** a token. The virtualizer needs the height as a\nnumber before it can lay rows out, so it comes from the `rowHeight`\nprop (default `30`) and is written as an inline style on each row. See\n[Row height](./rows/row-height.md).\n\n## Header typography tokens\n\nThe column-header label is themeable on its own so a preset can match\nits design system's grid header (bold, muted, small caps) without\ntouching the rest of the grid.\n\n| Token | Default | What it controls |\n| ------------------------- | ---------------------- | ------------------------------------------------------------------- |\n| `--sg-header-label-color` | inherits `--sg-header-fg` | Header label colour, when it should differ from the header text |\n| `--sg-header-weight` | `600` | Header label font weight |\n| `--sg-header-size` | inherits | Header label font size |\n| `--sg-header-transform` | `none` | Header label `text-transform` (e.g. `uppercase`) |\n| `--sg-header-tracking` | `normal` | Header label `letter-spacing` |\n| `--sg-header-min-height` | `auto` | Minimum height of a header cell |\n\n## Pinned-column tokens\n\n| Token | Default | What it paints |\n| ---------------------- | ----------- | ---------------------------------------------------------------------- |\n| `--sg-pinned-bg` | inherits `--sg-bg` | Pinned-column cell background (must be opaque to cover scroll under) |\n| `--sg-pinned-header-bg`| inherits `--sg-header-bg` | Pinned-column header background |\n| `--sg-pinned-divider` | `#cbd5e1` | 2px separator between pinned and scrolling region |\n| `--sg-pinned-border` | `color-mix(in oklab, var(--sg-accent) 24%, transparent)` | Top/bottom border of pinned **rows** |\n| `--sg-pinned-shadow-color` | `rgba(15,23,42,.22)` | Colour of the shadow a pinned column casts into the scroll area |\n\n## Surface tokens\n\n| Token | Default | What it paints |\n| ---------------------- | ------------------------- | ------------------------------------------------------------------ |\n| `--sg-bg-subtle` | inherits `--sg-header-bg` | Inset surfaces such as the master-detail region behind a nested grid |\n\n## Input + form tokens\n\n| Token | Default | What it paints |\n| ---------------------- | ---------------------- | -------------------------------------------------------------------- |\n| `--sg-input-bg` | inherits `--sg-bg` | Cell-editor `<input>` background |\n| `--sg-input-border` | inherits `--sg-border` | Cell-editor border |\n| `--sg-rating-on` | `#f59e0b` | Filled star colour in the rating editor |\n| `--sg-rating-empty` | `#cbd5e1` | Empty star colour |\n| `--sg-rating-hover` | `#fbbf24` | Star hover-state colour |\n\n## Scrollbar tokens\n\nThe grid uses a custom scrollbar so it looks consistent across OSes.\n\n| Token | Default |\n| -------------------------------- | ----------- |\n| `--sg-scrollbar-bg` | `transparent` |\n| `--sg-scrollbar-border` | inherits `--sg-border` |\n| `--sg-scrollbar-thumb` | `rgba(148,163,184,.45)` |\n| `--sg-scrollbar-thumb-hover` | `rgba(148,163,184,.65)` |\n| `--sg-scrollbar-thumb-active` | `rgba(148,163,184,.85)` |\n| `--sg-scrollbar-arrow` | inherits `--sg-muted` |\n| `--sg-scrollbar-arrow-hover` | inherits `--sg-fg` |\n| `--sg-scrollbar-arrow-hover-bg` | inherits `--sg-row-hover-bg` |\n| `--sg-scrollbar-arrow-active` | inherits `--sg-accent` |\n| `--sg-scrollbar-arrow-active-bg` | inherits `--sg-row-hover-bg` |\n| `--sg-scrollbar-arrow-disabled` | `rgba(148,163,184,.35)` |\n\n## Pill / status tokens (gallery-defined)\n\nThese are used by several demos but live in the gallery, not the\nlibrary. Copy if you want the same convention; rename freely otherwise.\n\n| Token | Used for |\n| --------------------------- | --------------------------------------- |\n| `--sg-pill-active` | \"Active\" status chip background |\n| `--sg-pill-active-fg` | \"Active\" status chip text |\n| `--sg-pill-pending` | \"Pending\" status chip background |\n| `--sg-pill-pending-fg` | \"Pending\" status chip text |\n| `--sg-pill-inactive` | \"Inactive\" status chip background |\n| `--sg-pill-inactive-fg` | \"Inactive\" status chip text |\n\n## How to override\n\n### Globally (your app's stylesheet)\n\n```css\n:root {\n --sg-accent: #db2777; /* hot pink everywhere */\n --sg-radius: 10px;\n --sg-font: 'Inter', sans-serif;\n}\n```\n\n### Per-grid (Tailwind / inline style)\n\n```svelte\n<div class=\"my-grid\" style=\"--sg-accent: #16a34a\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\n### Dark mode\n\nThe library doesn't ship a built-in dark theme - it reads whatever\nyour app provides. Most apps gate via `[data-theme=\"dark\"]` on\n`<html>`; sv-grid's only requirement is that you override the same\ntoken set under that selector:\n\n```css\n[data-theme=\"dark\"] {\n --sg-bg: #0f172a;\n --sg-fg: #f1f5f9;\n --sg-border: #1e293b;\n --sg-header-bg: #1e293b;\n --sg-row-hover-bg: rgba(148, 163, 184, 0.14);\n --sg-selection-bg: rgba(99, 102, 241, 0.18);\n}\n```\n\nTheme presets ship in\n[demo 74](https://svgrid.com/demos/74-theme-integrations/) for Ant\nDesign, MUI, Fluent, Base Web, and shadcn - each with a light AND a\ndark token bundle ready to copy.\n\n### Built-in design-system presets\n\n`@svgrid/grid` also ships 20 ready-made presets as plain stylesheets - import the\none you want and the grid re-skins immediately, light and dark both included\n(toggle with the same `[data-theme=\"dark\"]` attribute as above):\n\n```css\n@import '@svgrid/grid/themes/material.css';\n```\n\nAvailable ids: `ember`, `shadcn`, `tailwind`, `material`, `excel`, `fluent`,\n`carbon`, `sap`, `salesforce`, `atlassian`, `github`, `antd`, `ag-alpine`,\n`bootstrap`, `vercel`, `linear`, `notion`, `nord`, `dracula`, `catppuccin`.\n\n`ember` is SvGrid's own theme rather than a copy of an external design system:\nwarm neutrals with Svelte orange as the single accent. It is the palette\nsvgrid.com runs on, so one import reproduces the look of the grids on the site.\n\n## Stability promise\n\nCore surface tokens (the first table) are **stable**: they will not\nbe renamed or removed in a minor version. New tokens may be added.\nSee [API stability](./api-stability.md) for the full policy.\n\nLayout / pinned / scrollbar / input tokens follow the same promise.\n\nPill tokens are gallery-defined - the LIBRARY makes no promise about\nthem.\n\n## See also\n\n- [Tailwind integration](./tailwind.md) - how to wire tokens through\n Tailwind's theming layer\n- [Custom cells + themes](https://svgrid.com/demos/10-custom-cells-and-themes/) demo - the canonical token-override example\n- [Theme integrations](https://svgrid.com/demos/74-theme-integrations/) demo - five design-system presets side by side\n"
5291
+ "markdown": "# Design tokens\n\nEvery visual property the grid exposes for theming is a CSS custom\nproperty (`--sg-*`). Tokens cascade like any other CSS variable -\noverride at `:root`, on a wrapper element, or per-grid; the closest\ndeclaration wins.\n<div data-docs-demo=\"74-theme-integrations\" data-height=\"540\"></div>\n\n> No build step. No theme provider. No design-system lock-in. Set a\n> few `--sg-*` tokens and the grid re-skins immediately.\n\nLive in [demo 74 (Theme integrations)](https://svgrid.com/demos/74-theme-integrations/)\n- shows the same grid rendered through Ant Design, MUI, Fluent, Base\nWeb, and shadcn token sets.\n\n## Core surface tokens\n\nThese are the tokens 95% of integrations override. Stable; safe to\nship in a corporate design system.\n\n| Token | Default (light) | Default (dark) | What it paints |\n| ---------------------- | ---------------------- | ---------------------- | -------------------------------------------------------------------- |\n| `--sg-bg` | `#ffffff` | `#0f172a` | Grid background / cell fill |\n| `--sg-fg` | `#0f172a` | `#f1f5f9` | Primary text colour |\n| `--sg-muted` | `#64748b` | `#94a3b8` | Secondary text (header subtitles, placeholders, \"no data\" message) |\n| `--sg-border` | `#e2e8f0` | `#1e293b` | Cell borders, separators |\n| `--sg-header-bg` | `#f8fafc` | `#1e293b` | Column-header row background |\n| `--sg-header-fg` | inherits `--sg-fg` | inherits `--sg-fg` | Column-header text |\n| `--sg-header-border` | inherits `--sg-border` | inherits `--sg-border` | Line under the column-header row only (body cell borders unaffected) |\n| `--sg-row-alt-bg` | inherits `--sg-bg` | inherits `--sg-bg` | Zebra-stripe background for even-indexed rows |\n| `--sg-row-hover-bg` | `rgba(148,163,184,.10)`| `rgba(148,163,184,.14)`| Hover row background |\n| `--sg-selection-bg` | `rgba(99,102,241,.10)` | `rgba(99,102,241,.18)` | Selected row / selected cell-range fill |\n| `--sg-accent` | `#6366f1` | `#818cf8` | Active-cell ring, primary buttons, sort indicator |\n| `--sg-focus-ring` | `0 0 0 2px rgba(99,102,241,.40)` | same | Box-shadow used for the focus outline |\n\n## Chart series tokens\n\n| Token | Default | What it controls |\n| ------------------------ | --------- | ----------------------------------------- |\n| `--sg-chart-1` | `#2563eb` | First series colour |\n| `--sg-chart-2` | `#16a34a` | Second series |\n| `--sg-chart-3` | `#f59e0b` | Third series |\n| `--sg-chart-4` | `#ef4444` | Fourth series |\n| `--sg-chart-5` | `#8b5cf6` | Fifth series |\n| `--sg-chart-6` | `#0ea5e9` | Sixth series |\n| `--sg-chart-7` | `#ec4899` | Seventh series |\n| `--sg-chart-8` | `#14b8a6` | Eighth series |\n\nThe chart cycles through these in order, so a ninth series reuses the first.\nThey also colour the marks that have no series of their own, such as pie\nslices and tree-map cells.\n\nSet as few as you like; the rest keep their defaults, so a brand primary and\nsecondary is often the whole change:\n\n```css\n:root {\n --sg-chart-1: #7c3aed;\n --sg-chart-2: #db2777;\n}\n```\n\nThe tokens are read where the chart is mounted, so a wrapper can recolour one\nchart without touching the page:\n\n```svelte {runnable}\n<script lang=\"ts\">\n import { SvChart, type ChartSpec } from '@svgrid/grid'\n const spec: ChartSpec = {\n type: 'bar',\n categories: ['Q1', 'Q2', 'Q3', 'Q4'],\n series: [{ label: 'Plan', values: [40, 45, 50, 55] }, { label: 'Actual', values: [38, 49, 47, 61] }, { label: 'Stretch', values: [45, 50, 56, 62] }],\n height: 220,\n }\n</script>\n\n<div style=\"--sg-chart-1: #7c3aed; --sg-chart-2: #db2777; --sg-chart-3: #0d9488\">\n <SvChart {spec} />\n</div>\n```\n\nA spec's own `palette` beats the tokens, and a per-series `color` beats\nboth. The type gallery draws all twenty-nine types from the same eight\ntokens:\n\n<div data-docs-demo=\"435-chart-type-gallery\" data-height=\"600\"></div>\n\nPrecedence runs explicit-to-general: a `color` on the series wins, then\n`spec.palette` (or `charting.palette`), then these tokens, then the built-in\ncolours. The first two are the author speaking about one chart; the tokens are\nthe theme speaking about all of them.\n\n## Layout tokens\n\n| Token | Default | What it controls |\n| ---------------------- | ----------- | ---------------------------------------------------------------------- |\n| `--sg-radius` | `6px` | Border radius on rounded UI inside the grid (chips, badges, menus) |\n| `--sg-font` | inherits | Font family used inside the grid |\n\nRow height is **not** a token. The virtualizer needs the height as a\nnumber before it can lay rows out, so it comes from the `rowHeight`\nprop (default `30`) and is written as an inline style on each row. See\n[Row height](./rows/row-height.md).\n\n## Header typography tokens\n\nThe column-header label is themeable on its own so a preset can match\nits design system's grid header (bold, muted, small caps) without\ntouching the rest of the grid.\n\n| Token | Default | What it controls |\n| ------------------------- | ---------------------- | ------------------------------------------------------------------- |\n| `--sg-header-label-color` | inherits `--sg-header-fg` | Header label colour, when it should differ from the header text |\n| `--sg-header-weight` | `600` | Header label font weight |\n| `--sg-header-size` | inherits | Header label font size |\n| `--sg-header-transform` | `none` | Header label `text-transform` (e.g. `uppercase`) |\n| `--sg-header-tracking` | `normal` | Header label `letter-spacing` |\n| `--sg-header-min-height` | `auto` | Minimum height of a header cell |\n\n## Pinned-column tokens\n\n| Token | Default | What it paints |\n| ---------------------- | ----------- | ---------------------------------------------------------------------- |\n| `--sg-pinned-bg` | inherits `--sg-bg` | Pinned-column cell background (must be opaque to cover scroll under) |\n| `--sg-pinned-header-bg`| inherits `--sg-header-bg` | Pinned-column header background |\n| `--sg-pinned-divider` | `#cbd5e1` | 2px separator between pinned and scrolling region |\n| `--sg-pinned-border` | `color-mix(in oklab, var(--sg-accent) 24%, transparent)` | Top/bottom border of pinned **rows** |\n| `--sg-pinned-shadow-color` | `rgba(15,23,42,.22)` | Colour of the shadow a pinned column casts into the scroll area |\n\n## Surface tokens\n\n| Token | Default | What it paints |\n| ---------------------- | ------------------------- | ------------------------------------------------------------------ |\n| `--sg-bg-subtle` | inherits `--sg-header-bg` | Inset surfaces such as the master-detail region behind a nested grid |\n\n## Input + form tokens\n\n| Token | Default | What it paints |\n| ---------------------- | ---------------------- | -------------------------------------------------------------------- |\n| `--sg-input-bg` | inherits `--sg-bg` | Cell-editor `<input>` background |\n| `--sg-input-border` | inherits `--sg-border` | Cell-editor border |\n| `--sg-rating-on` | `#f59e0b` | Filled star colour in the rating editor |\n| `--sg-rating-empty` | `#cbd5e1` | Empty star colour |\n| `--sg-rating-hover` | `#fbbf24` | Star hover-state colour |\n\n## Scrollbar tokens\n\nThe grid uses a custom scrollbar so it looks consistent across OSes.\n\n| Token | Default |\n| -------------------------------- | ----------- |\n| `--sg-scrollbar-bg` | `transparent` |\n| `--sg-scrollbar-border` | inherits `--sg-border` |\n| `--sg-scrollbar-thumb` | `rgba(148,163,184,.45)` |\n| `--sg-scrollbar-thumb-hover` | `rgba(148,163,184,.65)` |\n| `--sg-scrollbar-thumb-active` | `rgba(148,163,184,.85)` |\n| `--sg-scrollbar-arrow` | inherits `--sg-muted` |\n| `--sg-scrollbar-arrow-hover` | inherits `--sg-fg` |\n| `--sg-scrollbar-arrow-hover-bg` | inherits `--sg-row-hover-bg` |\n| `--sg-scrollbar-arrow-active` | inherits `--sg-accent` |\n| `--sg-scrollbar-arrow-active-bg` | inherits `--sg-row-hover-bg` |\n| `--sg-scrollbar-arrow-disabled` | `rgba(148,163,184,.35)` |\n\n## Pill / status tokens (gallery-defined)\n\nThese are used by several demos but live in the gallery, not the\nlibrary. Copy if you want the same convention; rename freely otherwise.\n\n| Token | Used for |\n| --------------------------- | --------------------------------------- |\n| `--sg-pill-active` | \"Active\" status chip background |\n| `--sg-pill-active-fg` | \"Active\" status chip text |\n| `--sg-pill-pending` | \"Pending\" status chip background |\n| `--sg-pill-pending-fg` | \"Pending\" status chip text |\n| `--sg-pill-inactive` | \"Inactive\" status chip background |\n| `--sg-pill-inactive-fg` | \"Inactive\" status chip text |\n\n## How to override\n\n### Globally (your app's stylesheet)\n\n```css\n:root {\n --sg-accent: #db2777; /* hot pink everywhere */\n --sg-radius: 10px;\n --sg-font: 'Inter', sans-serif;\n}\n```\n\n### Per-grid (Tailwind / inline style)\n\n```svelte\n<div class=\"my-grid\" style=\"--sg-accent: #16a34a\">\n <SvGrid {data} {columns} features={features} />\n</div>\n```\n\n### Dark mode\n\nThe library doesn't ship a built-in dark theme - it reads whatever\nyour app provides. Most apps gate via `[data-theme=\"dark\"]` on\n`<html>`; sv-grid's only requirement is that you override the same\ntoken set under that selector:\n\n```css\n[data-theme=\"dark\"] {\n --sg-bg: #0f172a;\n --sg-fg: #f1f5f9;\n --sg-border: #1e293b;\n --sg-header-bg: #1e293b;\n --sg-row-hover-bg: rgba(148, 163, 184, 0.14);\n --sg-selection-bg: rgba(99, 102, 241, 0.18);\n}\n```\n\nTheme presets ship in\n[demo 74](https://svgrid.com/demos/74-theme-integrations/) for Ant\nDesign, MUI, Fluent, Base Web, and shadcn - each with a light AND a\ndark token bundle ready to copy.\n\n### Built-in design-system presets\n\n`@svgrid/grid` also ships 20 ready-made presets as plain stylesheets - import the\none you want and the grid re-skins immediately, light and dark both included\n(toggle with the same `[data-theme=\"dark\"]` attribute as above):\n\n```css\n@import '@svgrid/grid/themes/material.css';\n```\n\nAvailable ids: `ember`, `shadcn`, `tailwind`, `material`, `excel`, `fluent`,\n`carbon`, `sap`, `salesforce`, `atlassian`, `github`, `antd`, `ag-alpine`,\n`bootstrap`, `vercel`, `linear`, `notion`, `nord`, `dracula`, `catppuccin`.\n\n`ember` is SvGrid's own theme rather than a copy of an external design system:\nwarm neutrals with Svelte orange as the single accent. It is the palette\nsvgrid.com runs on, so one import reproduces the look of the grids on the site.\n\n## Stability promise\n\nCore surface tokens (the first table) are **stable**: they will not\nbe renamed or removed in a minor version. New tokens may be added.\nSee [API stability](./api-stability.md) for the full policy.\n\nLayout / pinned / scrollbar / input tokens follow the same promise.\n\nPill tokens are gallery-defined - the LIBRARY makes no promise about\nthem.\n\n## See also\n\n- [Figma design kit](./figma.md) - the same tokens as Figma variables, on\n [Figma Community](https://www.figma.com/community/file/1689644521333526144)\n- [Tailwind integration](./tailwind.md) - how to wire tokens through\n Tailwind's theming layer\n- [Custom cells + themes](https://svgrid.com/demos/10-custom-cells-and-themes/) demo - the canonical token-override example\n- [Theme integrations](https://svgrid.com/demos/74-theme-integrations/) demo - five design-system presets side by side\n"
5277
5292
  },
5278
5293
  {
5279
5294
  "slug": "help/troubleshooting",
@@ -5987,7 +6002,7 @@ export const docs = [
5987
6002
  "path": "docs/help/web-components.md",
5988
6003
  "title": "Web Components & Custom Elements",
5989
6004
  "section": "Help",
5990
- "markdown": "# Web Components & Custom Elements\n\nShip SvGrid as a framework-agnostic web component and use the Svelte 5 data grid\nin React, Vue, Angular, or plain HTML - no Svelte required in the host app.\n\n![React, Vue, Angular, and plain HTML each render the same sv-grid custom element, which wraps one shared SvGrid engine.](/docs-media/grid-web-components.svg)\n\nSvGrid is authored in Svelte 5, but Svelte compiles to standards-based\n[custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components).\n`@svgrid/grid-wc` publishes two of them, prebuilt: drop in one `<script>` tag\nand use `<sv-grid>` like any built-in element. If you searched for a \"web\ncomponent data grid\" or a \"framework-agnostic Svelte table\", this is the\nintegration path.\n\n## Start here\n\n| Page | What it covers |\n| --- | --- |\n| [**React, Vue or Angular**](./web-components/frameworks.md) | **Start here if you use one.** A running project in about thirty seconds, and 27 examples you can open and edit. |\n| [Quick start](./web-components/quick-start.md) | The CDN path, and the attribute-vs-property rule that trips everyone up once. |\n| [`<sv-grid>` reference](./web-components/sv-grid.md) | Every property, attribute and event. Generated from the grid's own types. |\n| [Shadow DOM](./web-components/shadow-dom.md) | `<sv-grid-shadow>`, for pages whose CSS you do not control. |\n| [React](./web-components/react.md) | The `@svgrid/grid-wc/react` component, and why React 18 needs it. |\n| [Vue](./web-components/vue.md) | The `@svgrid/grid-wc/vue` component - no `isCustomElement` config, no `.prop` modifiers. |\n| [Angular](./web-components/angular.md) | The `@svgrid/grid-wc/angular` standalone component, with typed inputs and outputs. |\n| [TypeScript](./web-components/typescript.md) | Typing the element, its properties and its events. |\n| [Enterprise features](./web-components/enterprise.md) | Excel / PDF export, import, print and pivot from a non-Svelte host - and the two views that need a Svelte-aware bundler. |\n| [Limitations](./web-components/limitations.md) | What cannot cross the boundary, and why. |\n\n## Framework wrappers\n\nThe package ships a component for each of the three big frameworks, generated\nfrom the same types as the element, as subpath imports:\n\n| Framework | Import |\n| --- | --- |\n| React | `import { SvGrid } from '@svgrid/grid-wc/react'` |\n| Vue | `import { SvGrid } from '@svgrid/grid-wc/vue'` |\n| Angular | `import { SvGridComponent } from '@svgrid/grid-wc/angular'` |\n\nEach framework is an OPTIONAL peer dependency, so a plain-HTML consumer never\ninstalls any of them, and each wrapper is a couple of KB that reuses the one\nelement bundle rather than shipping a second copy of the grid.\n\n**[Start here](./web-components/frameworks.md)** for the thirty-second path and\nnine runnable examples per framework - each one opens as a full editable\nproject, with no local setup.\n\n## Why a custom element\n\n- **Use it anywhere.** React, Vue, Angular, Solid, Astro, Rails/Laravel\n server-rendered pages, or a plain `<script>` tag. The browser treats\n `<sv-grid>` like any built-in element.\n- **One bundle, no framework lock-in.** Ship a single file to a CDN; consumers\n add one `<script type=\"module\">` and a tag.\n- **The whole grid.** 115 properties and 27 events, so virtualization,\n Excel-style filters, inline editing, grouping, tree data, pinning, pagination\n and the enterprise features are all reachable - not a hand-picked subset. The\n surface is **generated** from `<SvGrid>`'s own `Props` type and CI fails if\n the two drift. Exactly two props are left out, and the\n [reference](./web-components/sv-grid.md) says why.\n\nIf your team *is* on Svelte 5, use the component directly\n([First grid](../getting-started/2-first-grid.md)) - the custom element exists\nto reach non-Svelte hosts, and it costs you the Svelte runtime to do it.\n\n## Quick look\n\n```html\n<script type=\"module\" src=\"https://unpkg.com/@svgrid/grid-wc\"></script>\n\n<sv-grid id=\"grid\" sortable filterable pageable page-size=\"25\"\n style=\"display:block;height:420px\"></sv-grid>\n\n<script type=\"module\">\n const grid = document.getElementById('grid')\n // Arrays and objects are properties; primitives can be attributes.\n grid.columns = [{ field: 'name', header: 'Name' }, { field: 'role', header: 'Role' }]\n grid.data = [{ name: 'Ada', role: 'Engineering' }, { name: 'Alan', role: 'Research' }]\n grid.addEventListener('cellvaluechange', (e) => console.log(e.detail))\n</script>\n```\n\nFull walkthrough in [Quick start](./web-components/quick-start.md).\n\n## Building your own element\n\nThe published package is the recommended path, but the wrapper is about sixty\nlines and you may want a narrower surface, your own tag name, or extra\nbehaviour. The source is `packages/grid-wc/src` in the repository:\n`sv-grid-element.svelte` is the light-DOM wrapper, `GridBody.svelte` is the part\nboth elements share, and `scripts/generate-surface.mjs` writes the prop and\nevent declarations from `<SvGrid>`'s `Props` type.\n\n## See also\n\n- [Enterprise features from the element](./web-components/enterprise.md) -\n export, import, print and pivot need no Svelte in your build; the board and\n scheduler views do.\n- [Tailwind integration](./tailwind.md) - the full `--sg-*` token list.\n"
6005
+ "markdown": "# Web Components & Custom Elements\n\nShip SvGrid as a framework-agnostic web component and use the Svelte 5 data grid\nin React, Vue, Angular, or plain HTML - no Svelte required in the host app.\n\n![React, Vue, Angular, and plain HTML each render the same sv-grid custom element, which wraps one shared SvGrid engine.](/docs-media/grid-web-components.svg)\n\nSvGrid is authored in Svelte 5, but Svelte compiles to standards-based\n[custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components).\n`@svgrid/grid-wc` publishes two of them, prebuilt: drop in one `<script>` tag\nand use `<sv-grid>` like any built-in element. If you searched for a \"web\ncomponent data grid\" or a \"framework-agnostic Svelte table\", this is the\nintegration path.\n\n<!-- tutorial:mk-web-component -->\n<figure class=\"docs-tutorial docs-tutorial-player\" id=\"tutorial-mk-web-component\" data-docs-tutorial=\"mk-web-component\">\n<video class=\"docs-tutorial-video\" src=\"/tutorials/mk-web-component.mp4\" poster=\"/tutorials/mk-web-component.poster.webp\" width=\"960\" height=\"540\" controls playsinline preload=\"none\" aria-label=\"A data grid web component: one script tag, any framework, 133 second tutorial\"><track kind=\"captions\" srclang=\"en\" label=\"English\" src=\"/tutorials/mk-web-component.vtt\">Your browser does not play embedded video. <a href=\"/tutorials/mk-web-component.mp4\">Download the MP4</a>.</video>\n<figcaption><strong>A data grid web component: one script tag, any framework</strong> (2:13, with narration). <a href=\"https://www.youtube.com/watch?v=_zWVutDXlVY\" rel=\"noopener\">Watch on YouTube</a></figcaption>\n</figure>\n<!-- /tutorial:mk-web-component -->\n\n## Start here\n\n| Page | What it covers |\n| --- | --- |\n| [**React, Vue or Angular**](./web-components/frameworks.md) | **Start here if you use one.** A running project in about thirty seconds, and 27 examples you can open and edit. |\n| [Quick start](./web-components/quick-start.md) | The CDN path, and the attribute-vs-property rule that trips everyone up once. |\n| [`<sv-grid>` reference](./web-components/sv-grid.md) | Every property, attribute and event. Generated from the grid's own types. |\n| [Shadow DOM](./web-components/shadow-dom.md) | `<sv-grid-shadow>`, for pages whose CSS you do not control. |\n| [React](./web-components/react.md) | The `@svgrid/grid-wc/react` component, and why React 18 needs it. |\n| [Vue](./web-components/vue.md) | The `@svgrid/grid-wc/vue` component - no `isCustomElement` config, no `.prop` modifiers. |\n| [Angular](./web-components/angular.md) | The `@svgrid/grid-wc/angular` standalone component, with typed inputs and outputs. |\n| [TypeScript](./web-components/typescript.md) | Typing the element, its properties and its events. |\n| [Enterprise features](./web-components/enterprise.md) | Excel / PDF export, import, print and pivot from a non-Svelte host - and the two views that need a Svelte-aware bundler. |\n| [Limitations](./web-components/limitations.md) | What cannot cross the boundary, and why. |\n\n## Framework wrappers\n\nThe package ships a component for each of the three big frameworks, generated\nfrom the same types as the element, as subpath imports:\n\n| Framework | Import |\n| --- | --- |\n| React | `import { SvGrid } from '@svgrid/grid-wc/react'` |\n| Vue | `import { SvGrid } from '@svgrid/grid-wc/vue'` |\n| Angular | `import { SvGridComponent } from '@svgrid/grid-wc/angular'` |\n\nEach framework is an OPTIONAL peer dependency, so a plain-HTML consumer never\ninstalls any of them, and each wrapper is a couple of KB that reuses the one\nelement bundle rather than shipping a second copy of the grid.\n\n**[Start here](./web-components/frameworks.md)** for the thirty-second path and\nnine runnable examples per framework - each one opens as a full editable\nproject, with no local setup.\n\n## Why a custom element\n\n- **Use it anywhere.** React, Vue, Angular, Solid, Astro, Rails/Laravel\n server-rendered pages, or a plain `<script>` tag. The browser treats\n `<sv-grid>` like any built-in element.\n- **One bundle, no framework lock-in.** Ship a single file to a CDN; consumers\n add one `<script type=\"module\">` and a tag.\n- **The whole grid.** 116 properties and 27 events, so virtualization,\n Excel-style filters, inline editing, grouping, tree data, pinning, pagination\n and the enterprise features are all reachable - not a hand-picked subset. The\n surface is **generated** from `<SvGrid>`'s own `Props` type and CI fails if\n the two drift. Exactly two props are left out, and the\n [reference](./web-components/sv-grid.md) says why.\n\nIf your team *is* on Svelte 5, use the component directly\n([First grid](../getting-started/2-first-grid.md)) - the custom element exists\nto reach non-Svelte hosts, and it costs you the Svelte runtime to do it.\n\n## Quick look\n\n```html\n<script type=\"module\" src=\"https://unpkg.com/@svgrid/grid-wc\"></script>\n\n<sv-grid id=\"grid\" sortable filterable pageable page-size=\"25\"\n style=\"display:block;height:420px\"></sv-grid>\n\n<script type=\"module\">\n const grid = document.getElementById('grid')\n // Arrays and objects are properties; primitives can be attributes.\n grid.columns = [{ field: 'name', header: 'Name' }, { field: 'role', header: 'Role' }]\n grid.data = [{ name: 'Ada', role: 'Engineering' }, { name: 'Alan', role: 'Research' }]\n grid.addEventListener('cellvaluechange', (e) => console.log(e.detail))\n</script>\n```\n\nFull walkthrough in [Quick start](./web-components/quick-start.md).\n\n## Building your own element\n\nThe published package is the recommended path, but the wrapper is about sixty\nlines and you may want a narrower surface, your own tag name, or extra\nbehaviour. The source is `packages/grid-wc/src` in the repository:\n`sv-grid-element.svelte` is the light-DOM wrapper, `GridBody.svelte` is the part\nboth elements share, and `scripts/generate-surface.mjs` writes the prop and\nevent declarations from `<SvGrid>`'s `Props` type.\n\n## See also\n\n- [Enterprise features from the element](./web-components/enterprise.md) -\n export, import, print and pivot need no Svelte in your build; the board and\n scheduler views do.\n- [Tailwind integration](./tailwind.md) - the full `--sg-*` token list.\n"
5991
6006
  },
5992
6007
  {
5993
6008
  "slug": "help/web-components/angular",
@@ -6008,7 +6023,7 @@ export const docs = [
6008
6023
  "path": "docs/help/web-components/frameworks.md",
6009
6024
  "title": "SvGrid with React, Vue or Angular",
6010
6025
  "section": "help/web-components",
6011
- "markdown": "# SvGrid with React, Vue or Angular\n\nPick your framework and you have a running grid in about thirty seconds - no\nlocal setup, nothing to configure.\n\n## The 30-second path\n\n**1.** Install one package:\n\n<div data-docs-install=\"@svgrid/grid-wc\"></div>\n\n**2.** Import the component for your framework and render it:\n\n| Framework | Import | Tag |\n| --- | --- | --- |\n| React | `import { SvGrid } from '@svgrid/grid-wc/react'` | `<SvGrid data={rows} columns={cols} sortable filterable />` |\n| Vue | `import { SvGrid } from '@svgrid/grid-wc/vue'` | `<SvGrid :data=\"rows\" :columns=\"cols\" sortable filterable />` |\n| Angular | `import { SvGridComponent } from '@svgrid/grid-wc/angular'` | `<sv-grid [data]=\"rows\" [columns]=\"cols\" [sortable]=\"true\" />` |\n\nThat is the whole integration. All 115 properties and 27 events of the grid are\ntyped props on every one of them.\n\n**Or skip step 1** and open a working project right now:\n\n<div data-docs-sandbox=\"react:basic\" data-title=\"A first grid\"></div>\n<div data-docs-sandbox=\"vue:basic\" data-title=\"A first grid\"></div>\n<div data-docs-sandbox=\"angular:basic\" data-title=\"A first grid\"></div>\n\n## The examples\n\n<!-- BEGIN generated recipe table - packages/grid-wc/scripts/sync-example-docs.mjs -->\n\nThe same nine apps in each framework, so you can compare them\ndirectly - only the framework differs, never the data or the grid\nconfiguration. Every one is compiled in CI, so what you open is what works.\n\n| Recipe | What it shows | React | Vue | Angular |\n| --- | --- | --- | --- | --- |\n| A first grid | Rows, columns, and the two features almost every table wants | [→](./react.md#a-first-grid) | [→](./vue.md#a-first-grid) | [→](./angular.md#a-first-grid) |\n| Sorting and filtering | A filter row under the headers, multi-column sort, and the current sort read back into your own state | [→](./react.md#sorting-and-filtering) | [→](./vue.md#sorting-and-filtering) | [→](./angular.md#sorting-and-filtering) |\n| Editing and saving | Inline editing, with each committed edit arriving through `cellvaluechange` | [→](./react.md#editing-and-saving) | [→](./vue.md#editing-and-saving) | [→](./angular.md#editing-and-saving) |\n| Row selection | Checkboxes, with the selected rows handed straight to you - both the selection map and the rows themselves | [→](./react.md#row-selection) | [→](./vue.md#row-selection) | [→](./angular.md#row-selection) |\n| Grouping and totals | Group by one or two columns with an aggregate in the group row | [→](./react.md#grouping-and-totals) | [→](./vue.md#grouping-and-totals) | [→](./angular.md#grouping-and-totals) |\n| Pagination | Client-side paging | [→](./react.md#pagination) | [→](./vue.md#pagination) | [→](./angular.md#pagination) |\n| Server-side data | The grid renders the page you hand it and tells you when the user wants another | [→](./react.md#server-side-data) | [→](./vue.md#server-side-data) | [→](./angular.md#server-side-data) |\n| Theming | The `--sg-*` custom properties | [→](./react.md#theming) | [→](./vue.md#theming) | [→](./angular.md#theming) |\n| Excel export (Enterprise) | The paid pack from a non-Svelte host | [→](./react.md#excel-export-enterprise) | [→](./vue.md#excel-export-enterprise) | [→](./angular.md#excel-export-enterprise) |\n\n<!-- END generated recipe table -->\n## The chart element\n\nThe standalone chart ships as its own element, `<sv-chart>`, in the same\npackage: `import '@svgrid/grid-wc/chart'` registers it, and\n`@svgrid/grid-wc/react/chart`, `@svgrid/grid-wc/vue/chart` and the\n`SvChartComponent` export of the Angular package wrap it the same way the\ngrid is wrapped. Set `spec` as a property; the primitives (`zoomable`,\n`range-presets`, `legend=\"right\"`, `animate`, `drawable`) are attributes.\nThe [`<sv-chart>` reference](sv-chart.md) lists all of it, and the\n[charts guide](../charts.md) says what goes in a spec. It carries none of the\ngrid, so a page that only charts loads the chart.\n\n## What each wrapper is actually for\n\nThe grid is a custom element underneath, and a custom element is awkward in each\nframework in a different way. Each wrapper removes exactly that awkwardness:\n\n- **React** - React 18 and earlier stringify object props onto **attributes**,\n so `columns={cols}` silently becomes `\"[object Object]\"` and the grid renders\n empty with no error. The wrapper assigns them as properties on every version.\n- **Vue** - removes the `isCustomElement` build config and the `.prop` modifier\n that every object binding would otherwise need.\n- **Angular** - removes `CUSTOM_ELEMENTS_SCHEMA` from every component that shows\n a grid, and gives you typed `@Input` / `@Output` instead of an untyped\n element. Its selector is the element's own tag, so there is no extra wrapper\n element in your DOM.\n\nAll three also handle two ordering problems you would otherwise meet yourself:\nthe element renders **before** a framework assigns properties in an effect, and\n`apiready` fires once during that first mount - before React can bind a listener\nat all.\n\n## The imperative api\n\nEvery wrapper exposes the grid api once it is ready:\n\n- **React** - a ref: `grid.current?.api`\n- **Vue** - a template ref: `grid.value?.api`\n- **Angular** - the component instance: `grid.api`\n\n## Not using a framework?\n\n`<sv-grid>` works in plain HTML with one `<script>` tag - see\n[Quick start](./quick-start.md).\n\n## See also\n\n- [`<sv-grid>` reference](./sv-grid.md) - every property, attribute and event.\n- [Shadow DOM](./shadow-dom.md) - for pages whose CSS you do not control.\n- [TypeScript](./typescript.md) - typing the raw element, if you use it directly.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n"
6026
+ "markdown": "# SvGrid with React, Vue or Angular\n\nPick your framework and you have a running grid in about thirty seconds - no\nlocal setup, nothing to configure.\n\n## The 30-second path\n\n**1.** Install one package:\n\n<div data-docs-install=\"@svgrid/grid-wc\"></div>\n\n**2.** Import the component for your framework and render it:\n\n| Framework | Import | Tag |\n| --- | --- | --- |\n| React | `import { SvGrid } from '@svgrid/grid-wc/react'` | `<SvGrid data={rows} columns={cols} sortable filterable />` |\n| Vue | `import { SvGrid } from '@svgrid/grid-wc/vue'` | `<SvGrid :data=\"rows\" :columns=\"cols\" sortable filterable />` |\n| Angular | `import { SvGridComponent } from '@svgrid/grid-wc/angular'` | `<sv-grid [data]=\"rows\" [columns]=\"cols\" [sortable]=\"true\" />` |\n\nThat is the whole integration. All 116 properties and 27 events of the grid are\ntyped props on every one of them.\n\n**Or skip step 1** and open a working project right now:\n\n<div data-docs-sandbox=\"react:basic\" data-title=\"A first grid\"></div>\n<div data-docs-sandbox=\"vue:basic\" data-title=\"A first grid\"></div>\n<div data-docs-sandbox=\"angular:basic\" data-title=\"A first grid\"></div>\n\n## The examples\n\n<!-- BEGIN generated recipe table - packages/grid-wc/scripts/sync-example-docs.mjs -->\n\nThe same nine apps in each framework, so you can compare them\ndirectly - only the framework differs, never the data or the grid\nconfiguration. Every one is compiled in CI, so what you open is what works.\n\n| Recipe | What it shows | React | Vue | Angular |\n| --- | --- | --- | --- | --- |\n| A first grid | Rows, columns, and the two features almost every table wants | [→](./react.md#a-first-grid) | [→](./vue.md#a-first-grid) | [→](./angular.md#a-first-grid) |\n| Sorting and filtering | A filter row under the headers, multi-column sort, and the current sort read back into your own state | [→](./react.md#sorting-and-filtering) | [→](./vue.md#sorting-and-filtering) | [→](./angular.md#sorting-and-filtering) |\n| Editing and saving | Inline editing, with each committed edit arriving through `cellvaluechange` | [→](./react.md#editing-and-saving) | [→](./vue.md#editing-and-saving) | [→](./angular.md#editing-and-saving) |\n| Row selection | Checkboxes, with the selected rows handed straight to you - both the selection map and the rows themselves | [→](./react.md#row-selection) | [→](./vue.md#row-selection) | [→](./angular.md#row-selection) |\n| Grouping and totals | Group by one or two columns with an aggregate in the group row | [→](./react.md#grouping-and-totals) | [→](./vue.md#grouping-and-totals) | [→](./angular.md#grouping-and-totals) |\n| Pagination | Client-side paging | [→](./react.md#pagination) | [→](./vue.md#pagination) | [→](./angular.md#pagination) |\n| Server-side data | The grid renders the page you hand it and tells you when the user wants another | [→](./react.md#server-side-data) | [→](./vue.md#server-side-data) | [→](./angular.md#server-side-data) |\n| Theming | The `--sg-*` custom properties | [→](./react.md#theming) | [→](./vue.md#theming) | [→](./angular.md#theming) |\n| Excel export (Enterprise) | The paid pack from a non-Svelte host | [→](./react.md#excel-export-enterprise) | [→](./vue.md#excel-export-enterprise) | [→](./angular.md#excel-export-enterprise) |\n\n<!-- END generated recipe table -->\n## The chart element\n\nThe standalone chart ships as its own element, `<sv-chart>`, in the same\npackage: `import '@svgrid/grid-wc/chart'` registers it, and\n`@svgrid/grid-wc/react/chart`, `@svgrid/grid-wc/vue/chart` and the\n`SvChartComponent` export of the Angular package wrap it the same way the\ngrid is wrapped. Set `spec` as a property; the primitives (`zoomable`,\n`range-presets`, `legend=\"right\"`, `animate`, `drawable`) are attributes.\nThe [`<sv-chart>` reference](sv-chart.md) lists all of it, and the\n[charts guide](../charts.md) says what goes in a spec. It carries none of the\ngrid, so a page that only charts loads the chart.\n\n## What each wrapper is actually for\n\nThe grid is a custom element underneath, and a custom element is awkward in each\nframework in a different way. Each wrapper removes exactly that awkwardness:\n\n- **React** - React 18 and earlier stringify object props onto **attributes**,\n so `columns={cols}` silently becomes `\"[object Object]\"` and the grid renders\n empty with no error. The wrapper assigns them as properties on every version.\n- **Vue** - removes the `isCustomElement` build config and the `.prop` modifier\n that every object binding would otherwise need.\n- **Angular** - removes `CUSTOM_ELEMENTS_SCHEMA` from every component that shows\n a grid, and gives you typed `@Input` / `@Output` instead of an untyped\n element. Its selector is the element's own tag, so there is no extra wrapper\n element in your DOM.\n\nAll three also handle two ordering problems you would otherwise meet yourself:\nthe element renders **before** a framework assigns properties in an effect, and\n`apiready` fires once during that first mount - before React can bind a listener\nat all.\n\n## The imperative api\n\nEvery wrapper exposes the grid api once it is ready:\n\n- **React** - a ref: `grid.current?.api`\n- **Vue** - a template ref: `grid.value?.api`\n- **Angular** - the component instance: `grid.api`\n\n## Not using a framework?\n\n`<sv-grid>` works in plain HTML with one `<script>` tag - see\n[Quick start](./quick-start.md).\n\n## See also\n\n- [`<sv-grid>` reference](./sv-grid.md) - every property, attribute and event.\n- [Shadow DOM](./shadow-dom.md) - for pages whose CSS you do not control.\n- [TypeScript](./typescript.md) - typing the raw element, if you use it directly.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n"
6012
6027
  },
6013
6028
  {
6014
6029
  "slug": "help/web-components/limitations",
@@ -6029,14 +6044,14 @@ export const docs = [
6029
6044
  "path": "docs/help/web-components/react.md",
6030
6045
  "title": "SvGrid in React",
6031
6046
  "section": "help/web-components",
6032
- "markdown": "# SvGrid in React\n\n```bash\nnpm install @svgrid/grid-wc\n```\n\n```tsx\nimport { SvGrid } from '@svgrid/grid-wc/react'\n\nexport default function Screen() {\n return (\n <div style={{ height: 480 }}>\n <SvGrid\n data={rows}\n columns={columns}\n sortable\n filterable\n editable\n onCellValueChange={(e) => save(e)}\n />\n </div>\n )\n}\n```\n\nThat is the whole integration. The component is generated from `<SvGrid>`'s\nown types, so all 115 properties and 27 events are typed props, and it works the\nsame on React 17, 18 and 19.\n\n## Why the wrapper rather than the raw element\n\n- **React 18 and earlier stringify object props onto attributes.**\n `columns={cols}` becomes the attribute `\"[object Object]\"` and the grid\n renders empty, with no error. React 19 assigns them as properties; the wrapper\n does so on every version.\n- **The element renders before your props arrive.** Both React and Angular\n create the element first and assign properties in an effect, so the grid\n mounts once with nothing. The wrapper handles that ordering.\n- **`apiready` fires once, during that first mount** - before React can bind a\n listener, so a raw `addEventListener` misses it entirely. The wrapper replays\n it.\n- Typed props and handlers, instead of `el.columns = ...` on an `HTMLElement`.\n\n## Shadow DOM\n\n`shadow` picks the style-isolated element. Same props, same events; the extra\nbundle loads only when you ask for it.\n\n```tsx\n<SvGrid shadow data={rows} columns={columns} />\n```\n\n## The imperative api\n\n```tsx\nimport { useRef } from 'react'\nimport { SvGrid, type SvGridHandle } from '@svgrid/grid-wc/react'\n\nexport function Screen() {\n const grid = useRef<SvGridHandle>(null)\n\n const exportCsv = () => {\n // `api` is the grid api; `element` is the host custom element.\n ;(grid.current?.api as { exportCsv(): void } | undefined)?.exportCsv()\n }\n\n return <SvGrid ref={grid} data={rows} columns={columns} />\n}\n```\n\n<!-- BEGIN generated examples - packages/grid-wc/scripts/sync-example-docs.mjs -->\n\n## Examples\n\nNine complete apps. Each one is **running on this page** - sort it,\nfilter it, edit a cell - above the code that produces it. **Open in StackBlitz**\nthen boots the whole project, editable, with no local install and nothing to\nconfigure, and every one is compiled in this repository's CI, so what you open\nis what works.\n\nThey all share the same typed `data.ts`, so the only thing that changes\nbetween recipes is the grid.\n\nA note on the previews: the grid you can touch is driven by a Svelte host\nrather than a React one. It is not a recording or a\nlookalike - it renders through the same component `<sv-grid>` renders, with\nthe same data and the same props as the listing below it, so the only thing\nthat differs from the React app is who sets those props. Click **Open in\nStackBlitz** to run the real React one.\n\n### A first grid\n\nRows, columns, and the two features almost every table wants.\n\n<div data-docs-mirror=\"basic\" data-height=\"420\"></div>\n\n<div data-docs-sandbox=\"react:basic\" data-title=\"A first grid\"></div>\n\n```tsx {nocheck}\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * The smallest useful grid: rows, columns, and the two features almost every\n * table wants. `data` and `columns` are arrays, so they cross as properties -\n * the wrapper handles that on every React version.\n */\nexport default function App() {\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid data={people} columns={columns} sortable filterable />\n </div>\n )\n}\n```\n\n### Sorting and filtering\n\nA filter row under the headers, multi-column sort, and the current sort read back into your own state.\n\n<div data-docs-mirror=\"sorting-filtering\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:sorting-filtering\" data-title=\"Sorting and filtering\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * A filter row under the headers, multi-column sort, and the current sort read\n * back into React state. `onSortingChange` hands you the same array the grid\n * holds, so it can drive your own UI.\n */\nexport default function App() {\n const [sorting, setSorting] = useState<Array<{ id: string; desc: boolean }>>([])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <p style={{ margin: 0, font: '13px system-ui', color: '#64748b' }}>\n Sorted by:{' '}\n {sorting.length === 0\n ? 'nothing yet - click a header, then shift-click a second one'\n : sorting.map((s) => `${s.id} ${s.desc ? 'desc' : 'asc'}`).join(', ')}\n </p>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n showFilterRow\n onSortingChange={setSorting}\n />\n </div>\n </div>\n )\n}\n```\n\n### Editing and saving\n\nInline editing, with each committed edit arriving through `cellvaluechange`. Swap the local update for your save call.\n\n<div data-docs-mirror=\"editing\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:editing\" data-title=\"Editing and saving\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\n/**\n * Inline editing, with the change written back into React state.\n *\n * `cellvaluechange` fires once per committed edit and its detail is typed, so\n * `rowIndex`, `columnId` and `newValue` come through without a cast. Replace\n * the setState with your own save call.\n */\nexport default function App() {\n const [rows, setRows] = useState<Person[]>(people)\n const [log, setLog] = useState<string[]>([])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <p style={{ margin: 0, font: '13px system-ui', color: '#64748b' }}>\n Double-click a Name or Amount cell. Last edits: {log.slice(-3).join(' · ') || 'none yet'}\n </p>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={rows}\n columns={columns.map((c) =>\n c.id === 'name' || c.id === 'amount' ? { ...c, editable: true } : c,\n )}\n sortable\n filterable\n editable\n onCellValueChange={(e) => {\n setRows((prev) =>\n prev.map((row, i) => (i === e.rowIndex ? { ...row, [e.columnId]: e.newValue } : row)),\n )\n setLog((prev) => [...prev, `${e.columnId} = ${String(e.newValue)}`])\n }}\n />\n </div>\n </div>\n )\n}\n```\n\n### Row selection\n\nCheckboxes, with the selected rows handed straight to you - both the selection map and the rows themselves.\n\n<div data-docs-mirror=\"selection\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:selection\" data-title=\"Row selection\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\n/**\n * Row checkboxes, with the selected rows handed to React so you can act on\n * them. `rowselectionchange` carries both the selection map and the rows\n * themselves, so you rarely need to look anything up.\n */\nexport default function App() {\n const [selected, setSelected] = useState<Person[]>([])\n const total = selected.reduce((sum, r) => sum + r.amount, 0)\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <div style={{ font: '13px system-ui', color: '#64748b' }}>\n {selected.length === 0\n ? 'Tick some rows to total them'\n : `${selected.length} selected · ${total.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n showRowSelection\n onRowSelectionChange={(e) => setSelected(e.rows as Person[])}\n />\n </div>\n </div>\n )\n}\n```\n\n### Grouping and totals\n\nGroup by one or two columns with an aggregate in the group row. `groupBy` is an array, so it is one of the props that can only be a property.\n\n<div data-docs-mirror=\"grouping\" data-height=\"470\"></div>\n\n<div data-docs-sandbox=\"react:grouping\" data-title=\"Grouping and totals\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Grouping by one or two columns, with an aggregate in the group row.\n *\n * `groupBy` is an array, so it is one of the props that can only be a\n * property - which is the whole reason the React wrapper exists.\n */\nexport default function App() {\n const [groupBy, setGroupBy] = useState<string[]>(['team'])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <div style={{ display: 'flex', gap: 8, font: '13px system-ui' }}>\n {[['team'], ['country'], ['team', 'country'], []].map((g) => (\n <button\n key={g.join('+') || 'none'}\n onClick={() => setGroupBy(g)}\n style={{\n padding: '4px 10px',\n borderRadius: 6,\n border: '1px solid #cbd5e1',\n background: groupBy.join() === g.join() ? '#e2e8f0' : '#fff',\n cursor: 'pointer',\n }}\n >\n {g.join(' + ') || 'No grouping'}\n </button>\n ))}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns.map((c) => (c.id === 'amount' ? { ...c, aggregate: 'sum' } : c))}\n sortable\n filterable\n groupable\n groupBy={groupBy}\n groupFooters\n />\n </div>\n </div>\n )\n}\n```\n\n### Pagination\n\nClient-side paging. `pageSize` is the INITIAL page size, read once at mount.\n\n<div data-docs-mirror=\"pagination\" data-height=\"440\"></div>\n\n<div data-docs-sandbox=\"react:pagination\" data-title=\"Pagination\"></div>\n\n```tsx {nocheck}\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Client-side paging. `pageSize` is read once at mount - it is the INITIAL\n * page size - so change pages through the footer or the grid api rather than\n * by reassigning the prop.\n */\nexport default function App() {\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n pageable\n pageSize={25}\n pageSizeOptions={[10, 25, 50, 100]}\n paginationPosition=\"bottom\"\n />\n </div>\n )\n}\n```\n\n### Server-side data\n\nThe grid renders the page you hand it and tells you when the user wants another. `externalSort` and `externalPagination` stop it doing the work locally; `rowCount` is how it knows how many pages exist.\n\n<div data-docs-mirror=\"server-data\" data-height=\"440\"></div>\n\n<div data-docs-sandbox=\"react:server-data\" data-title=\"Server-side data\"></div>\n\n```tsx {nocheck}\nimport { useEffect, useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\ntype Query = { page: number; sort: Array<{ id: string; desc: boolean }> }\n\n/**\n * Server-side data: the grid renders whatever page you hand it and tells you\n * when the user wants a different one.\n *\n * `externalSort` and `externalPagination` stop the grid doing the work\n * locally; `rowCount` is how it knows how many pages exist. `fetchPage` here\n * stands in for your API call.\n */\nconst PAGE_SIZE = 25\n\nfunction fetchPage({ page, sort }: Query): Promise<{ rows: Person[]; total: number }> {\n // Pretend this is a network call.\n const sorted = [...people].sort((a, b) => {\n for (const s of sort) {\n const av = a[s.id as keyof Person]\n const bv = b[s.id as keyof Person]\n if (av === bv) continue\n return (av > bv ? 1 : -1) * (s.desc ? -1 : 1)\n }\n return 0\n })\n const start = page * PAGE_SIZE\n return new Promise((resolve) =>\n setTimeout(() => resolve({ rows: sorted.slice(start, start + PAGE_SIZE), total: sorted.length }), 250),\n )\n}\n\nexport default function App() {\n const [query, setQuery] = useState<Query>({ page: 0, sort: [] })\n const [rows, setRows] = useState<Person[]>([])\n const [total, setTotal] = useState(0)\n const [loading, setLoading] = useState(true)\n\n useEffect(() => {\n let cancelled = false\n setLoading(true)\n fetchPage(query).then((res) => {\n if (cancelled) return\n setRows(res.rows)\n setTotal(res.total)\n setLoading(false)\n })\n return () => {\n cancelled = true\n }\n }, [query])\n\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid\n data={rows}\n columns={columns}\n loading={loading}\n sortable\n externalSort\n externalPagination\n pageable\n pageSize={PAGE_SIZE}\n rowCount={total}\n pageIndex={query.page}\n onSortingChange={(sort) => setQuery((q) => ({ ...q, sort, page: 0 }))}\n onPaginationChange={(p) => setQuery((q) => ({ ...q, page: (p as { pageIndex: number }).pageIndex }))}\n />\n </div>\n )\n}\n```\n\n### Theming\n\nThe `--sg-*` custom properties. Ordinary CSS custom properties, so they cascade from any ancestor - which is why they also reach inside `<sv-grid-shadow>`.\n\n<div data-docs-mirror=\"theming\" data-height=\"470\"></div>\n\n<div data-docs-sandbox=\"react:theming\" data-title=\"Theming\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Theming through `--sg-*` custom properties.\n *\n * They are ordinary CSS custom properties, so they cascade: set them on any\n * ancestor and the grid inherits. That is also why they reach into\n * `<sv-grid-shadow>` - inheritance crosses a shadow boundary.\n */\nconst THEMES = {\n Light: {},\n Dark: {\n '--sg-bg': '#0b1220',\n '--sg-fg': '#e2e8f0',\n '--sg-border': '#1e293b',\n '--sg-header-bg': '#111a2e',\n '--sg-row-hover': '#111a2e',\n },\n Warm: {\n '--sg-bg': '#fffaf5',\n '--sg-fg': '#42302a',\n '--sg-border': '#f0dcc9',\n '--sg-header-bg': '#fdf1e4',\n '--sg-accent': '#c2410c',\n },\n} satisfies Record<string, Record<string, string>>\n\nexport default function App() {\n const [theme, setTheme] = useState<keyof typeof THEMES>('Light')\n\n return (\n <div\n style={{\n height: '100%',\n padding: 16,\n display: 'flex',\n flexDirection: 'column',\n gap: 12,\n ...THEMES[theme],\n }}\n >\n <div style={{ display: 'flex', gap: 8 }}>\n {(Object.keys(THEMES) as Array<keyof typeof THEMES>).map((name) => (\n <button\n key={name}\n onClick={() => setTheme(name)}\n style={{\n padding: '4px 10px',\n borderRadius: 6,\n border: '1px solid #cbd5e1',\n background: theme === name ? '#e2e8f0' : '#fff',\n cursor: 'pointer',\n }}\n >\n {name}\n </button>\n ))}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid data={people} columns={columns} sortable filterable zebraRows />\n </div>\n </div>\n )\n}\n```\n\n### Excel export (Enterprise)\n\nThe paid pack from a non-Svelte host. `@svgrid/enterprise/export` is plain JavaScript, so it needs no Svelte in your build - the same goes for `/import`, `/print`, `/pivot` and `/license`. See [Enterprise features](./enterprise.md) for what those subpaths cover and what needs a Svelte-aware bundler.\n\n<div data-docs-mirror=\"enterprise\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:enterprise\" data-title=\"Excel export (Enterprise)\"></div>\n\n```tsx {nocheck}\nimport { useRef, useState } from 'react'\nimport { SvGrid, type SvGridHandle } from '@svgrid/grid-wc/react'\nimport { exportGrid } from '@svgrid/enterprise/export'\nimport { people, columns } from '../data'\n\n/**\n * Excel export from a React host.\n *\n * `@svgrid/enterprise/export` is plain JavaScript - no Svelte in your build -\n * so this works in any bundler. The same is true of `/import`, `/print`,\n * `/pivot` and `/license`. The Kanban and scheduler views are the exception:\n * they are Svelte components, so they need a Svelte-aware bundler.\n *\n * The api arrives on the handle once `apiready` has fired. It is typed\n * `unknown` there because the wrapper does not depend on the grid's types, so\n * the enterprise call needs a cast.\n */\nexport default function App() {\n const grid = useRef<SvGridHandle>(null)\n const [ready, setReady] = useState(false)\n const [busy, setBusy] = useState(false)\n\n async function exportXlsx() {\n if (!grid.current?.api) return\n setBusy(true)\n try {\n await exportGrid(grid.current.api as Parameters<typeof exportGrid>[0], {\n format: 'xlsx',\n filename: 'people',\n })\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <button type=\"button\" onClick={exportXlsx} disabled={!ready || busy}>\n {busy ? 'Building…' : 'Export to Excel'}\n </button>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n ref={grid}\n data={people}\n columns={columns}\n sortable\n filterable\n onApiReady={() => setReady(true)}\n />\n </div>\n </div>\n )\n}\n```\n\n<!-- END generated examples -->\n\n## Rendering into cells\n\nYou cannot put a React component inside a cell: cell rendering is a Svelte\ncompile-time feature, and there is nothing to hand across the boundary. Column\n`format` options, `fieldFn` and HTML-string renderers cover badges, links and\nformatted values.\n\nColumn `format` and HTML-string renderers, which is what to reach for instead:\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"480\"></div>\n\nIf a grid's cells must render your own components, use the Svelte component\ndirectly. See [limitations](./limitations.md).\n\n## Why it lives in `@svgrid/grid-wc`\n\nRather than a separate `@svgrid/react` package: one version to keep in step\nwith the element instead of two, and `react` is an OPTIONAL peer dependency, so\na plain-HTML consumer never installs React. The wrapper is a subpath import and\nabout 1.4 KB - it reuses the one element bundle rather than shipping a second\ncopy of the grid.\n\n## The raw element\n\n`<SvGrid>` wraps `<sv-grid>`, and nothing stops you using the element\ndirectly - it is the same grid, and the wrapper is a thin layer over it. You\nwould be taking on the property assignment, the event binding and the two\nordering problems the wrapper handles, so do it only if you have a reason to.\n\n[Quick start](./quick-start.md) covers the element, and the\n[reference](./sv-grid.md) lists every property, attribute and event.\n\n## See also\n\n- [All frameworks](./frameworks.md) - the same examples in Vue and Angular.\n- [`<sv-grid>` reference](./sv-grid.md) - every property, attribute and event.\n- [TypeScript](./typescript.md) - typing the element, if you use it directly.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n"
6047
+ "markdown": "# SvGrid in React\n\n```bash\nnpm install @svgrid/grid-wc\n```\n\n```tsx\nimport { SvGrid } from '@svgrid/grid-wc/react'\n\nexport default function Screen() {\n return (\n <div style={{ height: 480 }}>\n <SvGrid\n data={rows}\n columns={columns}\n sortable\n filterable\n editable\n onCellValueChange={(e) => save(e)}\n />\n </div>\n )\n}\n```\n\nThat is the whole integration. The component is generated from `<SvGrid>`'s\nown types, so all 116 properties and 27 events are typed props, and it works the\nsame on React 17, 18 and 19.\n\n## Why the wrapper rather than the raw element\n\n- **React 18 and earlier stringify object props onto attributes.**\n `columns={cols}` becomes the attribute `\"[object Object]\"` and the grid\n renders empty, with no error. React 19 assigns them as properties; the wrapper\n does so on every version.\n- **The element renders before your props arrive.** Both React and Angular\n create the element first and assign properties in an effect, so the grid\n mounts once with nothing. The wrapper handles that ordering.\n- **`apiready` fires once, during that first mount** - before React can bind a\n listener, so a raw `addEventListener` misses it entirely. The wrapper replays\n it.\n- Typed props and handlers, instead of `el.columns = ...` on an `HTMLElement`.\n\n## Shadow DOM\n\n`shadow` picks the style-isolated element. Same props, same events; the extra\nbundle loads only when you ask for it.\n\n```tsx\n<SvGrid shadow data={rows} columns={columns} />\n```\n\n## The imperative api\n\n```tsx\nimport { useRef } from 'react'\nimport { SvGrid, type SvGridHandle } from '@svgrid/grid-wc/react'\n\nexport function Screen() {\n const grid = useRef<SvGridHandle>(null)\n\n const exportCsv = () => {\n // `api` is the grid api; `element` is the host custom element.\n ;(grid.current?.api as { exportCsv(): void } | undefined)?.exportCsv()\n }\n\n return <SvGrid ref={grid} data={rows} columns={columns} />\n}\n```\n\n<!-- BEGIN generated examples - packages/grid-wc/scripts/sync-example-docs.mjs -->\n\n## Examples\n\nNine complete apps. Each one is **running on this page** - sort it,\nfilter it, edit a cell - above the code that produces it. **Open in StackBlitz**\nthen boots the whole project, editable, with no local install and nothing to\nconfigure, and every one is compiled in this repository's CI, so what you open\nis what works.\n\nThey all share the same typed `data.ts`, so the only thing that changes\nbetween recipes is the grid.\n\nA note on the previews: the grid you can touch is driven by a Svelte host\nrather than a React one. It is not a recording or a\nlookalike - it renders through the same component `<sv-grid>` renders, with\nthe same data and the same props as the listing below it, so the only thing\nthat differs from the React app is who sets those props. Click **Open in\nStackBlitz** to run the real React one.\n\n### A first grid\n\nRows, columns, and the two features almost every table wants.\n\n<div data-docs-mirror=\"basic\" data-height=\"420\"></div>\n\n<div data-docs-sandbox=\"react:basic\" data-title=\"A first grid\"></div>\n\n```tsx {nocheck}\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * The smallest useful grid: rows, columns, and the two features almost every\n * table wants. `data` and `columns` are arrays, so they cross as properties -\n * the wrapper handles that on every React version.\n */\nexport default function App() {\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid data={people} columns={columns} sortable filterable />\n </div>\n )\n}\n```\n\n### Sorting and filtering\n\nA filter row under the headers, multi-column sort, and the current sort read back into your own state.\n\n<div data-docs-mirror=\"sorting-filtering\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:sorting-filtering\" data-title=\"Sorting and filtering\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * A filter row under the headers, multi-column sort, and the current sort read\n * back into React state. `onSortingChange` hands you the same array the grid\n * holds, so it can drive your own UI.\n */\nexport default function App() {\n const [sorting, setSorting] = useState<Array<{ id: string; desc: boolean }>>([])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <p style={{ margin: 0, font: '13px system-ui', color: '#64748b' }}>\n Sorted by:{' '}\n {sorting.length === 0\n ? 'nothing yet - click a header, then shift-click a second one'\n : sorting.map((s) => `${s.id} ${s.desc ? 'desc' : 'asc'}`).join(', ')}\n </p>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n showFilterRow\n onSortingChange={setSorting}\n />\n </div>\n </div>\n )\n}\n```\n\n### Editing and saving\n\nInline editing, with each committed edit arriving through `cellvaluechange`. Swap the local update for your save call.\n\n<div data-docs-mirror=\"editing\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:editing\" data-title=\"Editing and saving\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\n/**\n * Inline editing, with the change written back into React state.\n *\n * `cellvaluechange` fires once per committed edit and its detail is typed, so\n * `rowIndex`, `columnId` and `newValue` come through without a cast. Replace\n * the setState with your own save call.\n */\nexport default function App() {\n const [rows, setRows] = useState<Person[]>(people)\n const [log, setLog] = useState<string[]>([])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <p style={{ margin: 0, font: '13px system-ui', color: '#64748b' }}>\n Double-click a Name or Amount cell. Last edits: {log.slice(-3).join(' · ') || 'none yet'}\n </p>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={rows}\n columns={columns.map((c) =>\n c.id === 'name' || c.id === 'amount' ? { ...c, editable: true } : c,\n )}\n sortable\n filterable\n editable\n onCellValueChange={(e) => {\n setRows((prev) =>\n prev.map((row, i) => (i === e.rowIndex ? { ...row, [e.columnId]: e.newValue } : row)),\n )\n setLog((prev) => [...prev, `${e.columnId} = ${String(e.newValue)}`])\n }}\n />\n </div>\n </div>\n )\n}\n```\n\n### Row selection\n\nCheckboxes, with the selected rows handed straight to you - both the selection map and the rows themselves.\n\n<div data-docs-mirror=\"selection\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:selection\" data-title=\"Row selection\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\n/**\n * Row checkboxes, with the selected rows handed to React so you can act on\n * them. `rowselectionchange` carries both the selection map and the rows\n * themselves, so you rarely need to look anything up.\n */\nexport default function App() {\n const [selected, setSelected] = useState<Person[]>([])\n const total = selected.reduce((sum, r) => sum + r.amount, 0)\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <div style={{ font: '13px system-ui', color: '#64748b' }}>\n {selected.length === 0\n ? 'Tick some rows to total them'\n : `${selected.length} selected · ${total.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n showRowSelection\n onRowSelectionChange={(e) => setSelected(e.rows as Person[])}\n />\n </div>\n </div>\n )\n}\n```\n\n### Grouping and totals\n\nGroup by one or two columns with an aggregate in the group row. `groupBy` is an array, so it is one of the props that can only be a property.\n\n<div data-docs-mirror=\"grouping\" data-height=\"470\"></div>\n\n<div data-docs-sandbox=\"react:grouping\" data-title=\"Grouping and totals\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Grouping by one or two columns, with an aggregate in the group row.\n *\n * `groupBy` is an array, so it is one of the props that can only be a\n * property - which is the whole reason the React wrapper exists.\n */\nexport default function App() {\n const [groupBy, setGroupBy] = useState<string[]>(['team'])\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <div style={{ display: 'flex', gap: 8, font: '13px system-ui' }}>\n {[['team'], ['country'], ['team', 'country'], []].map((g) => (\n <button\n key={g.join('+') || 'none'}\n onClick={() => setGroupBy(g)}\n style={{\n padding: '4px 10px',\n borderRadius: 6,\n border: '1px solid #cbd5e1',\n background: groupBy.join() === g.join() ? '#e2e8f0' : '#fff',\n cursor: 'pointer',\n }}\n >\n {g.join(' + ') || 'No grouping'}\n </button>\n ))}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n data={people}\n columns={columns.map((c) => (c.id === 'amount' ? { ...c, aggregate: 'sum' } : c))}\n sortable\n filterable\n groupable\n groupBy={groupBy}\n groupFooters\n />\n </div>\n </div>\n )\n}\n```\n\n### Pagination\n\nClient-side paging. `pageSize` is the INITIAL page size, read once at mount.\n\n<div data-docs-mirror=\"pagination\" data-height=\"440\"></div>\n\n<div data-docs-sandbox=\"react:pagination\" data-title=\"Pagination\"></div>\n\n```tsx {nocheck}\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Client-side paging. `pageSize` is read once at mount - it is the INITIAL\n * page size - so change pages through the footer or the grid api rather than\n * by reassigning the prop.\n */\nexport default function App() {\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid\n data={people}\n columns={columns}\n sortable\n filterable\n pageable\n pageSize={25}\n pageSizeOptions={[10, 25, 50, 100]}\n paginationPosition=\"bottom\"\n />\n </div>\n )\n}\n```\n\n### Server-side data\n\nThe grid renders the page you hand it and tells you when the user wants another. `externalSort` and `externalPagination` stop it doing the work locally; `rowCount` is how it knows how many pages exist.\n\n<div data-docs-mirror=\"server-data\" data-height=\"440\"></div>\n\n<div data-docs-sandbox=\"react:server-data\" data-title=\"Server-side data\"></div>\n\n```tsx {nocheck}\nimport { useEffect, useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns, type Person } from '../data'\n\ntype Query = { page: number; sort: Array<{ id: string; desc: boolean }> }\n\n/**\n * Server-side data: the grid renders whatever page you hand it and tells you\n * when the user wants a different one.\n *\n * `externalSort` and `externalPagination` stop the grid doing the work\n * locally; `rowCount` is how it knows how many pages exist. `fetchPage` here\n * stands in for your API call.\n */\nconst PAGE_SIZE = 25\n\nfunction fetchPage({ page, sort }: Query): Promise<{ rows: Person[]; total: number }> {\n // Pretend this is a network call.\n const sorted = [...people].sort((a, b) => {\n for (const s of sort) {\n const av = a[s.id as keyof Person]\n const bv = b[s.id as keyof Person]\n if (av === bv) continue\n return (av > bv ? 1 : -1) * (s.desc ? -1 : 1)\n }\n return 0\n })\n const start = page * PAGE_SIZE\n return new Promise((resolve) =>\n setTimeout(() => resolve({ rows: sorted.slice(start, start + PAGE_SIZE), total: sorted.length }), 250),\n )\n}\n\nexport default function App() {\n const [query, setQuery] = useState<Query>({ page: 0, sort: [] })\n const [rows, setRows] = useState<Person[]>([])\n const [total, setTotal] = useState(0)\n const [loading, setLoading] = useState(true)\n\n useEffect(() => {\n let cancelled = false\n setLoading(true)\n fetchPage(query).then((res) => {\n if (cancelled) return\n setRows(res.rows)\n setTotal(res.total)\n setLoading(false)\n })\n return () => {\n cancelled = true\n }\n }, [query])\n\n return (\n <div style={{ height: '100%', padding: 16 }}>\n <SvGrid\n data={rows}\n columns={columns}\n loading={loading}\n sortable\n externalSort\n externalPagination\n pageable\n pageSize={PAGE_SIZE}\n rowCount={total}\n pageIndex={query.page}\n onSortingChange={(sort) => setQuery((q) => ({ ...q, sort, page: 0 }))}\n onPaginationChange={(p) => setQuery((q) => ({ ...q, page: (p as { pageIndex: number }).pageIndex }))}\n />\n </div>\n )\n}\n```\n\n### Theming\n\nThe `--sg-*` custom properties. Ordinary CSS custom properties, so they cascade from any ancestor - which is why they also reach inside `<sv-grid-shadow>`.\n\n<div data-docs-mirror=\"theming\" data-height=\"470\"></div>\n\n<div data-docs-sandbox=\"react:theming\" data-title=\"Theming\"></div>\n\n```tsx {nocheck}\nimport { useState } from 'react'\nimport { SvGrid } from '@svgrid/grid-wc/react'\nimport { people, columns } from '../data'\n\n/**\n * Theming through `--sg-*` custom properties.\n *\n * They are ordinary CSS custom properties, so they cascade: set them on any\n * ancestor and the grid inherits. That is also why they reach into\n * `<sv-grid-shadow>` - inheritance crosses a shadow boundary.\n */\nconst THEMES = {\n Light: {},\n Dark: {\n '--sg-bg': '#0b1220',\n '--sg-fg': '#e2e8f0',\n '--sg-border': '#1e293b',\n '--sg-header-bg': '#111a2e',\n '--sg-row-hover': '#111a2e',\n },\n Warm: {\n '--sg-bg': '#fffaf5',\n '--sg-fg': '#42302a',\n '--sg-border': '#f0dcc9',\n '--sg-header-bg': '#fdf1e4',\n '--sg-accent': '#c2410c',\n },\n} satisfies Record<string, Record<string, string>>\n\nexport default function App() {\n const [theme, setTheme] = useState<keyof typeof THEMES>('Light')\n\n return (\n <div\n style={{\n height: '100%',\n padding: 16,\n display: 'flex',\n flexDirection: 'column',\n gap: 12,\n ...THEMES[theme],\n }}\n >\n <div style={{ display: 'flex', gap: 8 }}>\n {(Object.keys(THEMES) as Array<keyof typeof THEMES>).map((name) => (\n <button\n key={name}\n onClick={() => setTheme(name)}\n style={{\n padding: '4px 10px',\n borderRadius: 6,\n border: '1px solid #cbd5e1',\n background: theme === name ? '#e2e8f0' : '#fff',\n cursor: 'pointer',\n }}\n >\n {name}\n </button>\n ))}\n </div>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid data={people} columns={columns} sortable filterable zebraRows />\n </div>\n </div>\n )\n}\n```\n\n### Excel export (Enterprise)\n\nThe paid pack from a non-Svelte host. `@svgrid/enterprise/export` is plain JavaScript, so it needs no Svelte in your build - the same goes for `/import`, `/print`, `/pivot` and `/license`. See [Enterprise features](./enterprise.md) for what those subpaths cover and what needs a Svelte-aware bundler.\n\n<div data-docs-mirror=\"enterprise\" data-height=\"460\"></div>\n\n<div data-docs-sandbox=\"react:enterprise\" data-title=\"Excel export (Enterprise)\"></div>\n\n```tsx {nocheck}\nimport { useRef, useState } from 'react'\nimport { SvGrid, type SvGridHandle } from '@svgrid/grid-wc/react'\nimport { exportGrid } from '@svgrid/enterprise/export'\nimport { people, columns } from '../data'\n\n/**\n * Excel export from a React host.\n *\n * `@svgrid/enterprise/export` is plain JavaScript - no Svelte in your build -\n * so this works in any bundler. The same is true of `/import`, `/print`,\n * `/pivot` and `/license`. The Kanban and scheduler views are the exception:\n * they are Svelte components, so they need a Svelte-aware bundler.\n *\n * The api arrives on the handle once `apiready` has fired. It is typed\n * `unknown` there because the wrapper does not depend on the grid's types, so\n * the enterprise call needs a cast.\n */\nexport default function App() {\n const grid = useRef<SvGridHandle>(null)\n const [ready, setReady] = useState(false)\n const [busy, setBusy] = useState(false)\n\n async function exportXlsx() {\n if (!grid.current?.api) return\n setBusy(true)\n try {\n await exportGrid(grid.current.api as Parameters<typeof exportGrid>[0], {\n format: 'xlsx',\n filename: 'people',\n })\n } finally {\n setBusy(false)\n }\n }\n\n return (\n <div style={{ height: '100%', padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>\n <button type=\"button\" onClick={exportXlsx} disabled={!ready || busy}>\n {busy ? 'Building…' : 'Export to Excel'}\n </button>\n <div style={{ flex: 1, minHeight: 0 }}>\n <SvGrid\n ref={grid}\n data={people}\n columns={columns}\n sortable\n filterable\n onApiReady={() => setReady(true)}\n />\n </div>\n </div>\n )\n}\n```\n\n<!-- END generated examples -->\n\n## Rendering into cells\n\nYou cannot put a React component inside a cell: cell rendering is a Svelte\ncompile-time feature, and there is nothing to hand across the boundary. Column\n`format` options, `fieldFn` and HTML-string renderers cover badges, links and\nformatted values.\n\nColumn `format` and HTML-string renderers, which is what to reach for instead:\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"480\"></div>\n\nIf a grid's cells must render your own components, use the Svelte component\ndirectly. See [limitations](./limitations.md).\n\n## Why it lives in `@svgrid/grid-wc`\n\nRather than a separate `@svgrid/react` package: one version to keep in step\nwith the element instead of two, and `react` is an OPTIONAL peer dependency, so\na plain-HTML consumer never installs React. The wrapper is a subpath import and\nabout 1.4 KB - it reuses the one element bundle rather than shipping a second\ncopy of the grid.\n\n## The raw element\n\n`<SvGrid>` wraps `<sv-grid>`, and nothing stops you using the element\ndirectly - it is the same grid, and the wrapper is a thin layer over it. You\nwould be taking on the property assignment, the event binding and the two\nordering problems the wrapper handles, so do it only if you have a reason to.\n\n[Quick start](./quick-start.md) covers the element, and the\n[reference](./sv-grid.md) lists every property, attribute and event.\n\n## See also\n\n- [All frameworks](./frameworks.md) - the same examples in Vue and Angular.\n- [`<sv-grid>` reference](./sv-grid.md) - every property, attribute and event.\n- [TypeScript](./typescript.md) - typing the element, if you use it directly.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n"
6033
6048
  },
6034
6049
  {
6035
6050
  "slug": "help/web-components/shadow-dom",
6036
6051
  "path": "docs/help/web-components/shadow-dom.md",
6037
6052
  "title": "Shadow DOM: <sv-grid-shadow>",
6038
6053
  "section": "help/web-components",
6039
- "markdown": "# Shadow DOM: `<sv-grid-shadow>`\n\nTwo elements ship, and which one you want is a styling decision:\n\n| Element | Renders in | Page CSS | Use when |\n| --- | --- | --- | --- |\n| `<sv-grid>` | the light DOM | applies normally | Almost always - a data grid usually wants to inherit your app's theme. |\n| `<sv-grid-shadow>` | an open shadow root | **cannot reach the grid** | You are embedding in a page whose CSS you do not control, or shipping into someone else's site. |\n\nThey are two elements rather than one with a `shadow` attribute because Svelte's\n`customElement.shadow` is resolved when the component is COMPILED. No runtime\nflag can switch it, so no attribute can either.\n\n```html\n<script type=\"module\" src=\"https://unpkg.com/@svgrid/grid-wc/dist/shadow/sv-grid-shadow-element.js\"></script>\n\n<sv-grid-shadow id=\"grid\" sortable filterable style=\"display:block;height:420px\"></sv-grid-shadow>\n<script type=\"module\">\n const grid = document.getElementById('grid')\n grid.columns = [{ field: 'name', header: 'Name' }, { field: 'city', header: 'City' }]\n grid.data = [{ name: 'Ada', city: 'London' }, { name: 'Linus', city: 'Helsinki' }]\n</script>\n```\n\nEverything else is identical: the same 115 properties, the same 77 attributes,\nthe same 27 events. Events are dispatched `composed: true`, so\n`addEventListener` on the host works exactly as it does on `<sv-grid>`.\n\n\n## What the shadow root does and does not isolate\n\nTheming with `--sg-*` tokens, which reach into the shadow root by inheritance.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"500\"></div>\n\nThe root is **open**, never closed. A closed root leaves `el.shadowRoot` null,\nwhich would stop you styling, querying, or testing your own grid - so it is not\noffered.\n\nIsolation is one-directional, and worth being precise about:\n\n- **Page CSS cannot reach the grid.** A page rule like\n `table td { background: fuchsia }` hits `<sv-grid>` and stops at\n `<sv-grid-shadow>`'s boundary. That is the point of the element.\n- **The grid's CSS is still in your page.** About twenty overlay surfaces - the\n cell dropdown, the date picker, tooltips, toasts, modals - portal to\n `document.body` deliberately, so they escape every ancestor `overflow` and\n clip. Those land outside the shadow root, so the bundle injects its stylesheet\n into `document.head` as well as adopting it into the root. Every rule is\n scoped to an `.sv-grid-*` class, so it does not style your page, but it is\n present in it.\n- **Styles arrive with the code that needs them.** The entry carries the\n grid's own sheet; a lazy chunk (the chart, the date pickers, the menus)\n carries the styles of the components inside it and adds them to the page\n and to every open root when it loads. A page that never charts never\n downloads the chart's styles.\n\n## Theming\n\n`--sg-*` tokens work in both elements, unchanged:\n\n```css\nsv-grid, sv-grid-shadow {\n --sg-bg: #fff;\n --sg-header-bg: #f8fafc;\n}\n```\n\nA theme stylesheet works too, including its dark variant, even though its\nselectors are `:root` and `:root[data-theme='dark']` and neither can MATCH\ninside a shadow tree. Custom properties are *inherited* properties, and\ninheritance crosses a shadow boundary - so the values land on the host and are\ninherited by everything in the root. Verified in a browser rather than reasoned\nabout: it was assumed to be broken for a while, and it is not.\n\n\n## Constraints\n\nSelection, clipboard and the menus that portal out of the root - all identical inside a shadow tree.\n\n<div data-docs-demo=\"04-selection-copy-paste\" data-height=\"460\"></div>\n\n- **The root is resolved on mount.** Moving a live `<sv-grid-shadow>` into a\n different document needs a fresh element, not a reparent.\n- **Do not give the host a `transform`, `filter`, or `contain`.** Any of those\n makes it a containing block for fixed positioning, and the grid's menus -\n which are `position: fixed` - would anchor to the host instead of the\n viewport.\n- **Closed roots are unsupported**, for the reason above.\n\n\n## See also\n\n- [Quick start](./quick-start.md)\n- [`<sv-grid>` reference](./sv-grid.md)\n- [Limitations](./limitations.md)\n"
6054
+ "markdown": "# Shadow DOM: `<sv-grid-shadow>`\n\nTwo elements ship, and which one you want is a styling decision:\n\n| Element | Renders in | Page CSS | Use when |\n| --- | --- | --- | --- |\n| `<sv-grid>` | the light DOM | applies normally | Almost always - a data grid usually wants to inherit your app's theme. |\n| `<sv-grid-shadow>` | an open shadow root | **cannot reach the grid** | You are embedding in a page whose CSS you do not control, or shipping into someone else's site. |\n\nThey are two elements rather than one with a `shadow` attribute because Svelte's\n`customElement.shadow` is resolved when the component is COMPILED. No runtime\nflag can switch it, so no attribute can either.\n\n```html\n<script type=\"module\" src=\"https://unpkg.com/@svgrid/grid-wc/dist/shadow/sv-grid-shadow-element.js\"></script>\n\n<sv-grid-shadow id=\"grid\" sortable filterable style=\"display:block;height:420px\"></sv-grid-shadow>\n<script type=\"module\">\n const grid = document.getElementById('grid')\n grid.columns = [{ field: 'name', header: 'Name' }, { field: 'city', header: 'City' }]\n grid.data = [{ name: 'Ada', city: 'London' }, { name: 'Linus', city: 'Helsinki' }]\n</script>\n```\n\nEverything else is identical: the same 116 properties, the same 78 attributes,\nthe same 27 events. Events are dispatched `composed: true`, so\n`addEventListener` on the host works exactly as it does on `<sv-grid>`.\n\n\n## What the shadow root does and does not isolate\n\nTheming with `--sg-*` tokens, which reach into the shadow root by inheritance.\n\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"500\"></div>\n\nThe root is **open**, never closed. A closed root leaves `el.shadowRoot` null,\nwhich would stop you styling, querying, or testing your own grid - so it is not\noffered.\n\nIsolation is one-directional, and worth being precise about:\n\n- **Page CSS cannot reach the grid.** A page rule like\n `table td { background: fuchsia }` hits `<sv-grid>` and stops at\n `<sv-grid-shadow>`'s boundary. That is the point of the element.\n- **The grid's CSS is still in your page.** About twenty overlay surfaces - the\n cell dropdown, the date picker, tooltips, toasts, modals - portal to\n `document.body` deliberately, so they escape every ancestor `overflow` and\n clip. Those land outside the shadow root, so the bundle injects its stylesheet\n into `document.head` as well as adopting it into the root. Every rule is\n scoped to an `.sv-grid-*` class, so it does not style your page, but it is\n present in it.\n- **Styles arrive with the code that needs them.** The entry carries the\n grid's own sheet; a lazy chunk (the chart, the date pickers, the menus)\n carries the styles of the components inside it and adds them to the page\n and to every open root when it loads. A page that never charts never\n downloads the chart's styles.\n\n## Theming\n\n`--sg-*` tokens work in both elements, unchanged:\n\n```css\nsv-grid, sv-grid-shadow {\n --sg-bg: #fff;\n --sg-header-bg: #f8fafc;\n}\n```\n\nA theme stylesheet works too, including its dark variant, even though its\nselectors are `:root` and `:root[data-theme='dark']` and neither can MATCH\ninside a shadow tree. Custom properties are *inherited* properties, and\ninheritance crosses a shadow boundary - so the values land on the host and are\ninherited by everything in the root. Verified in a browser rather than reasoned\nabout: it was assumed to be broken for a while, and it is not.\n\n\n## Constraints\n\nSelection, clipboard and the menus that portal out of the root - all identical inside a shadow tree.\n\n<div data-docs-demo=\"04-selection-copy-paste\" data-height=\"460\"></div>\n\n- **The root is resolved on mount.** Moving a live `<sv-grid-shadow>` into a\n different document needs a fresh element, not a reparent.\n- **Do not give the host a `transform`, `filter`, or `contain`.** Any of those\n makes it a containing block for fixed positioning, and the grid's menus -\n which are `position: fixed` - would anchor to the host instead of the\n viewport.\n- **Closed roots are unsupported**, for the reason above.\n\n\n## See also\n\n- [Quick start](./quick-start.md)\n- [`<sv-grid>` reference](./sv-grid.md)\n- [Limitations](./limitations.md)\n"
6040
6055
  },
6041
6056
  {
6042
6057
  "slug": "help/web-components/sv-chart",
@@ -6050,7 +6065,7 @@ export const docs = [
6050
6065
  "path": "docs/help/web-components/sv-grid.md",
6051
6066
  "title": "<sv-grid> reference",
6052
6067
  "section": "help/web-components",
6053
- "markdown": "# `<sv-grid>` reference\n\nEvery property, attribute and event of the custom element.\n\n**This page is generated** from `<SvGrid>`'s own `Props` type by\n`packages/grid-wc/scripts/generate-surface.mjs`, and CI fails if it drifts. The\nelement used to declare 7 props and 2 events by hand while the docs claimed\n\"grouping, sorting, pagination all come along\" - a hand-written table is how\nthat happens.\n\n## Attributes vs properties\n\n`groupable` is an attribute; `groupBy` is an array, so it is property-only. This grid uses both.\n\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"480\"></div>\n\nThe distinction matters more here than anywhere else in the API:\n\n- **An attribute is a string.** Primitives (`boolean`, `number`, `string`) can\n be written in HTML: `<sv-grid page-size=\"25\" show-row-numbers>`.\n- **Arrays, objects and functions cannot.** `columns='[object Object]'` is not\n a thing. Assign them in script: `el.columns = [...]`.\n\nBoth work through the property, so `el.pageSize = 25` is equally valid. The\nattribute is the extra convenience, offered only where it can work.\n\n```html\n<sv-grid id=\"grid\" sortable filterable page-size=\"25\" row-height=\"32\"></sv-grid>\n<script type=\"module\">\n const grid = document.getElementById('grid')\n grid.columns = [{ field: 'name', header: 'Name' }] // property: an array\n grid.data = rows // property: an array\n</script>\n```\n\n<!-- BEGIN generated reference - packages/grid-wc/scripts/generate-surface.mjs -->\n\n### Attributes (77)\n\nPrimitives, so they work in plain HTML as well as through a property.\n\n| Attribute | Property | Type |\n| --- | --- | --- |\n| `pivot-mode` | `pivotMode` | `boolean` |\n| `context-menu` | `contextMenu` | `boolean \\| ReadonlyArray<ContextMenuItem<TData>>` |\n| `selection-bar` | `selectionBar` | `\\| boolean \\| ReadonlyArray<SelectionBarAction<TData> \\| SelectionBarBuiltin> \\| SelectionB...` |\n| `sortable` | `sortable` | `boolean` |\n| `filterable` | `filterable` | `boolean` |\n| `editable` | `editable` | `boolean` |\n| `groupable` | `groupable` | `boolean` |\n| `group-footers` | `groupFooters` | `boolean` |\n| `grand-total-row` | `grandTotalRow` | `boolean` |\n| `group-display-mode` | `groupDisplayMode` | `GroupDisplayType` |\n| `auto-group-column-header` | `autoGroupColumnHeader` | `string` |\n| `auto-group-column-width` | `autoGroupColumnWidth` | `number` |\n| `pageable` | `pageable` | `boolean` |\n| `loading` | `loading` | `boolean` |\n| `loading-overlay` | `loadingOverlay` | `boolean` |\n| `loading-skeleton-rows` | `loadingSkeletonRows` | `number` |\n| `error` | `error` | `string \\| null` |\n| `empty-message` | `emptyMessage` | `string` |\n| `show-global-filter` | `showGlobalFilter` | `boolean` |\n| `show-column-filters` | `showColumnFilters` | `boolean` |\n| `filter-mode` | `filterMode` | `\"row\" \\| \"menu\" \\| \"global\" \\| \"none\"` |\n| `show-grouping-controls` | `showGroupingControls` | `boolean` |\n| `show-row-selection` | `showRowSelection` | `boolean` |\n| `show-pagination` | `showPagination` | `boolean` |\n| `page-size` | `pageSize` | `number` |\n| `pagination-position` | `paginationPosition` | `'top' \\| 'bottom' \\| 'both'` |\n| `external-pagination` | `externalPagination` | `boolean` |\n| `row-count` | `rowCount` | `number` |\n| `page-index` | `pageIndex` | `number` |\n| `virtualization` | `virtualization` | `boolean` |\n| `row-height` | `rowHeight` | `number \\| ((rowIndex: number) => number)` |\n| `auto-row-height` | `autoRowHeight` | `boolean` |\n| `row-resize` | `rowResize` | `boolean` |\n| `header-height` | `headerHeight` | `number` |\n| `overscan` | `overscan` | `number` |\n| `container-height` | `containerHeight` | `number \\| string` |\n| `column-virtualization` | `columnVirtualization` | `boolean` |\n| `column-overscan` | `columnOverscan` | `number` |\n| `column-width` | `columnWidth` | `number` |\n| `fit-columns` | `fitColumns` | `boolean` |\n| `column-resize` | `columnResize` | `boolean` |\n| `responsive` | `responsive` | `boolean \\| { breakpoint?: number }` |\n| `show-filter-menu` | `showFilterMenu` | `boolean` |\n| `show-filter-row` | `showFilterRow` | `boolean` |\n| `enable-cell-selection` | `enableCellSelection` | `boolean` |\n| `move-cells` | `moveCells` | `boolean` |\n| `enable-row-hover` | `enableRowHover` | `boolean` |\n| `copy-headers-to-clipboard` | `copyHeadersToClipboard` | `boolean` |\n| `enable-inline-editing` | `enableInlineEditing` | `boolean` |\n| `edit-on-second-click` | `editOnSecondClick` | `boolean` |\n| `full-row-editing` | `fullRowEditing` | `boolean` |\n| `enable-row-summaries` | `enableRowSummaries` | `boolean` |\n| `summary` | `summary` | `boolean` |\n| `status-bar` | `statusBar` | `\\| boolean \\| { ... }` |\n| `tool-panel` | `toolPanel` | `boolean` |\n| `charting` | `charting` | `boolean \\| ChartingConfig<TData>` |\n| `column-menu-tabs` | `columnMenuTabs` | `boolean` |\n| `tool-panel-default-open` | `toolPanelDefaultOpen` | `boolean` |\n| `tool-panel-default-tab` | `toolPanelDefaultTab` | `\"columns\" \\| \"filters\"` |\n| `selection-mode` | `selectionMode` | `\"row\" \\| \"cell\" \\| \"both\" \\| \"none\"` |\n| `show-row-numbers` | `showRowNumbers` | `boolean` |\n| `zebra-rows` | `zebraRows` | `boolean` |\n| `row-number-width` | `rowNumberWidth` | `number` |\n| `external-sort` | `externalSort` | `boolean` |\n| `external-filter` | `externalFilter` | `boolean` |\n| `editable-comments` | `editableComments` | `boolean` |\n| `conditional-stat-scope` | `conditionalStatScope` | `\"filtered\" \\| \"visible\" \\| \"all\"` |\n| `detail-row-height` | `detailRowHeight` | `number \\| ((row: TData) => number)` |\n| `show-detail-toggle` | `showDetailToggle` | `boolean` |\n| `frozen-rows` | `frozenRows` | `number` |\n| `sticky-group-rows` | `stickyGroupRows` | `boolean` |\n| `enable-column-reorder` | `enableColumnReorder` | `boolean` |\n| `infer-column-types` | `inferColumnTypes` | `boolean` |\n| `row-drag-managed` | `rowDragManaged` | `boolean` |\n| `row-drag-group` | `rowDragGroup` | `string` |\n| `aligned-grid-group` | `alignedGridGroup` | `string` |\n| `filter-locale` | `filterLocale` | `string \\| ReadonlyArray<string>` |\n\n### Properties only (38)\n\nArrays, objects and functions. An HTML attribute is a string, so these can\nonly be assigned in script: `el.columns = [...]`.\n\n| Property | Type |\n| --- | --- |\n| `data` | `ReadonlyArray<TData>` |\n| `columns` | `Array<ColumnDef<TFeatures, TData>>` |\n| `board` | `BoardConfig<TFeatures, TData>` |\n| `scheduler` | `SchedulerConfig<TFeatures, TData>` |\n| `gantt` | `GanttConfig<TFeatures, TData>` |\n| `chart` | `ChartViewConfig<TFeatures, TData>` |\n| `pivot` | `GridPivotConfig<TData>` |\n| `features` | `TFeatures` |\n| `treeData` | `{ ... }` |\n| `groupBy` | `ReadonlyArray<string>` |\n| `expanded` | `Record<string, boolean>` |\n| `localization` | `GridLocalization` |\n| `pageSizeOptions` | `number[]` |\n| `initialColumnPinning` | `{ ... }` |\n| `processCellForFill` | `(params: { value: unknown; delta: { rows: number; cols: number }; rowIndex: number; col...` |\n| `processCellForClipboard` | `(params: { ... }) => unknown` |\n| `includeCollapsedRows` | `(rowIndex: number) => boolean` |\n| `clipboardHtml` | `(params: { rects: ReadonlyArray<{ ... }>; text: string; }) => string \\| null \\| undefined` |\n| `processCellFromClipboard` | `(params: { ... }) => unknown` |\n| `initialSorting` | `Array<{ id: string; desc: boolean }>` |\n| `initialAdvancedFilter` | `GridPredicateExpr \\| null` |\n| `getRowId` | `(row: TData, index: number) => string` |\n| `rowClass` | `(ctx: { row: TData; rowIndex: number; }) => string \\| ReadonlyArray<string> \\| Record<str...` |\n| `notes` | `Record<string, Record<string, string>>` |\n| `conditionalFormats` | `ReadonlyArray<ConditionalFormat<TData>>` |\n| `rowPlaceholder` | `(row: TData, rowIndex: number) => \"loading\" \\| \"failed\" \\| null` |\n| `rowModel` | `GridRowModel<TData>` |\n| `pivotResultColumns` | `Array<ColumnDef<TFeatures, TData>> \\| null` |\n| `rowSelectionModel` | `{ ... }` |\n| `isDetailRow` | `(row: TData, rowIndex: number) => boolean` |\n| `isDetailOpen` | `(row: TData) => boolean` |\n| `hasDetail` | `(row: TData) => boolean` |\n| `serverGroup` | `{ ... }` |\n| `serverFilterValues` | `(columnId: string) => Promise<string[]>` |\n| `pinnedTopRows` | `ReadonlyArray<TData>` |\n| `mergedCells` | `ReadonlyArray<{ ... }>` |\n| `pinnedBottomRows` | `ReadonlyArray<TData>` |\n| `columnOrder` | `ReadonlyArray<string>` |\n\n### Events (26)\n\n`detail` is the callback's argument. The one callback that takes two carries\nan object keyed by its parameter names.\n\n| Event | From | `detail` |\n| --- | --- | --- |\n| `pivotmodechange` | `onPivotModeChange` | `on` |\n| `expandedchange` | `onExpandedChange` | `expanded` |\n| `paginationchange` | `onPaginationChange` | `pagination` |\n| `columnresize` | `onColumnResize` | `event` |\n| `rowresize` | `onRowResize` | `event` |\n| `pasteclipboard` | `onPasteClipboard` | `payload` |\n| `apiready` | `onApiReady` | `api` |\n| `rowselectionchange` | `onRowSelectionChange` | `{ selection, rows }` |\n| `cellselectionchange` | `onCellSelectionChange` | `ranges` |\n| `sortingchange` | `onSortingChange` | `sorting` |\n| `advancedfilterchange` | `onAdvancedFilterChange` | `expr` |\n| `filterschange` | `onFiltersChange` | `filters` |\n| `notechange` | `onNoteChange` | `event` |\n| `cellvaluechange` | `onCellValueChange` | `event` |\n| `activecellchange` | `onActiveCellChange` | `cell` |\n| `cellclick` | `onCellClick` | `event` |\n| `rowclick` | `onRowClick` | `row` - published detail is the row, not the whole click event |\n| `celldoubleclick` | `onCellDoubleClick` | `event` |\n| `rowdoubleclick` | `onRowDoubleClick` | `event` |\n| `scrollbottomreached` | `onScrollBottomReached` | `event` |\n| `visiblerangechange` | `onVisibleRangeChange` | `range` |\n| `retryrow` | `onRetryRow` | `{ row, rowIndex }` |\n| `detailtoggle` | `onDetailToggle` | `{ row, rowIndex }` |\n| `columnorderchange` | `onColumnOrderChange` | `order` |\n| `rowdragend` | `onRowDragEnd` | `event` |\n| `rowdrop` | `onRowDrop` | `event` |\n\nPlus one alias kept from before the surface was generated:\n\n| Event | From | `detail` |\n| --- | --- | --- |\n| `selectionchange` | `onRowSelectionChange` | `rows` - published alias of rowselectionchange, detail is the selected rows |\n\n### Not exposed (3)\n\n| Prop | Why |\n| --- | --- |\n| `selectable` | element-level shorthand for row selection - see GridBody.svelte |\n| `icons` | Svelte snippets keyed by icon name - cannot cross the custom-element boundary |\n| `renderDetailRow` | Svelte snippet - cannot cross the custom-element boundary |\n\n<!-- END generated reference -->\n\n\n## The imperative API\n\nThe api surface `apiready` hands you.\n\n<div data-docs-demo=\"90-selection-api\" data-height=\"460\"></div>\n\n`onApiReady` fires once with the grid's api. Because a listener bound after\nmount would miss it, the handle is also parked on the element:\n\n```js\ngrid.addEventListener('apiready', (e) => e.detail.exportCsv())\n// or, at any time later:\ngrid.api.exportCsv()\n```\n\n\n## See also\n\n- [Quick start](./quick-start.md) - the CDN path, no build step.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n- [Shadow DOM](./shadow-dom.md) - `<sv-grid-shadow>` and style isolation.\n"
6068
+ "markdown": "# `<sv-grid>` reference\n\nEvery property, attribute and event of the custom element.\n\n**This page is generated** from `<SvGrid>`'s own `Props` type by\n`packages/grid-wc/scripts/generate-surface.mjs`, and CI fails if it drifts. The\nelement used to declare 7 props and 2 events by hand while the docs claimed\n\"grouping, sorting, pagination all come along\" - a hand-written table is how\nthat happens.\n\n## Attributes vs properties\n\n`groupable` is an attribute; `groupBy` is an array, so it is property-only. This grid uses both.\n\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"480\"></div>\n\nThe distinction matters more here than anywhere else in the API:\n\n- **An attribute is a string.** Primitives (`boolean`, `number`, `string`) can\n be written in HTML: `<sv-grid page-size=\"25\" show-row-numbers>`.\n- **Arrays, objects and functions cannot.** `columns='[object Object]'` is not\n a thing. Assign them in script: `el.columns = [...]`.\n\nBoth work through the property, so `el.pageSize = 25` is equally valid. The\nattribute is the extra convenience, offered only where it can work.\n\n```html\n<sv-grid id=\"grid\" sortable filterable page-size=\"25\" row-height=\"32\"></sv-grid>\n<script type=\"module\">\n const grid = document.getElementById('grid')\n grid.columns = [{ field: 'name', header: 'Name' }] // property: an array\n grid.data = rows // property: an array\n</script>\n```\n\n<!-- BEGIN generated reference - packages/grid-wc/scripts/generate-surface.mjs -->\n\n### Attributes (78)\n\nPrimitives, so they work in plain HTML as well as through a property.\n\n| Attribute | Property | Type |\n| --- | --- | --- |\n| `pivot-mode` | `pivotMode` | `boolean` |\n| `context-menu` | `contextMenu` | `boolean \\| ReadonlyArray<ContextMenuItem<TData>>` |\n| `selection-bar` | `selectionBar` | `\\| boolean \\| ReadonlyArray<SelectionBarAction<TData> \\| SelectionBarBuiltin> \\| SelectionB...` |\n| `sortable` | `sortable` | `boolean` |\n| `filterable` | `filterable` | `boolean` |\n| `editable` | `editable` | `boolean` |\n| `groupable` | `groupable` | `boolean` |\n| `group-footers` | `groupFooters` | `boolean` |\n| `grand-total-row` | `grandTotalRow` | `boolean` |\n| `group-display-mode` | `groupDisplayMode` | `GroupDisplayType` |\n| `auto-group-column-header` | `autoGroupColumnHeader` | `string` |\n| `auto-group-column-width` | `autoGroupColumnWidth` | `number` |\n| `pageable` | `pageable` | `boolean` |\n| `loading` | `loading` | `boolean` |\n| `loading-overlay` | `loadingOverlay` | `boolean` |\n| `loading-skeleton-rows` | `loadingSkeletonRows` | `number` |\n| `error` | `error` | `string \\| null` |\n| `empty-message` | `emptyMessage` | `string` |\n| `show-global-filter` | `showGlobalFilter` | `boolean` |\n| `show-column-filters` | `showColumnFilters` | `boolean` |\n| `filter-mode` | `filterMode` | `\"row\" \\| \"menu\" \\| \"global\" \\| \"none\"` |\n| `show-grouping-controls` | `showGroupingControls` | `boolean` |\n| `show-row-selection` | `showRowSelection` | `boolean` |\n| `show-pagination` | `showPagination` | `boolean` |\n| `page-size` | `pageSize` | `number` |\n| `pagination-position` | `paginationPosition` | `'top' \\| 'bottom' \\| 'both'` |\n| `external-pagination` | `externalPagination` | `boolean` |\n| `row-count` | `rowCount` | `number` |\n| `page-index` | `pageIndex` | `number` |\n| `virtualization` | `virtualization` | `boolean` |\n| `row-height` | `rowHeight` | `number \\| ((rowIndex: number) => number)` |\n| `auto-row-height` | `autoRowHeight` | `boolean` |\n| `row-resize` | `rowResize` | `boolean` |\n| `header-height` | `headerHeight` | `number` |\n| `overscan` | `overscan` | `number` |\n| `container-height` | `containerHeight` | `number \\| string` |\n| `column-virtualization` | `columnVirtualization` | `boolean` |\n| `column-overscan` | `columnOverscan` | `number` |\n| `column-width` | `columnWidth` | `number` |\n| `fit-columns` | `fitColumns` | `boolean` |\n| `column-resize` | `columnResize` | `boolean` |\n| `responsive` | `responsive` | `boolean \\| { breakpoint?: number }` |\n| `show-filter-menu` | `showFilterMenu` | `boolean` |\n| `show-filter-row` | `showFilterRow` | `boolean` |\n| `enable-cell-selection` | `enableCellSelection` | `boolean` |\n| `move-cells` | `moveCells` | `boolean` |\n| `enable-row-hover` | `enableRowHover` | `boolean` |\n| `copy-headers-to-clipboard` | `copyHeadersToClipboard` | `boolean` |\n| `enable-inline-editing` | `enableInlineEditing` | `boolean` |\n| `edit-on-second-click` | `editOnSecondClick` | `boolean` |\n| `full-row-editing` | `fullRowEditing` | `boolean` |\n| `enable-row-summaries` | `enableRowSummaries` | `boolean` |\n| `summary` | `summary` | `boolean` |\n| `status-bar` | `statusBar` | `\\| boolean \\| { ... }` |\n| `tool-panel` | `toolPanel` | `boolean` |\n| `charting` | `charting` | `boolean \\| ChartingConfig<TData>` |\n| `column-menu-tabs` | `columnMenuTabs` | `boolean` |\n| `tool-panel-default-open` | `toolPanelDefaultOpen` | `boolean` |\n| `tool-panel-default-tab` | `toolPanelDefaultTab` | `\"columns\" \\| \"filters\"` |\n| `selection-mode` | `selectionMode` | `\"row\" \\| \"cell\" \\| \"both\" \\| \"none\"` |\n| `show-row-numbers` | `showRowNumbers` | `boolean` |\n| `zebra-rows` | `zebraRows` | `boolean` |\n| `row-number-width` | `rowNumberWidth` | `number` |\n| `external-sort` | `externalSort` | `boolean` |\n| `external-filter` | `externalFilter` | `boolean` |\n| `editable-comments` | `editableComments` | `boolean` |\n| `conditional-stat-scope` | `conditionalStatScope` | `\"filtered\" \\| \"visible\" \\| \"all\"` |\n| `detail-row-height` | `detailRowHeight` | `number \\| ((row: TData) => number)` |\n| `show-detail-toggle` | `showDetailToggle` | `boolean` |\n| `frozen-rows` | `frozenRows` | `number` |\n| `sticky-group-rows` | `stickyGroupRows` | `boolean` |\n| `row-layout` | `rowLayout` | `\"flex\" \\| \"table\"` |\n| `enable-column-reorder` | `enableColumnReorder` | `boolean` |\n| `infer-column-types` | `inferColumnTypes` | `boolean` |\n| `row-drag-managed` | `rowDragManaged` | `boolean` |\n| `row-drag-group` | `rowDragGroup` | `string` |\n| `aligned-grid-group` | `alignedGridGroup` | `string` |\n| `filter-locale` | `filterLocale` | `string \\| ReadonlyArray<string>` |\n\n### Properties only (38)\n\nArrays, objects and functions. An HTML attribute is a string, so these can\nonly be assigned in script: `el.columns = [...]`.\n\n| Property | Type |\n| --- | --- |\n| `data` | `ReadonlyArray<TData>` |\n| `columns` | `Array<ColumnDef<TFeatures, TData>>` |\n| `board` | `BoardConfig<TFeatures, TData>` |\n| `scheduler` | `SchedulerConfig<TFeatures, TData>` |\n| `gantt` | `GanttConfig<TFeatures, TData>` |\n| `chart` | `ChartViewConfig<TFeatures, TData>` |\n| `pivot` | `GridPivotConfig<TData>` |\n| `features` | `TFeatures` |\n| `treeData` | `{ ... }` |\n| `groupBy` | `ReadonlyArray<string>` |\n| `expanded` | `Record<string, boolean>` |\n| `localization` | `GridLocalization` |\n| `pageSizeOptions` | `number[]` |\n| `initialColumnPinning` | `{ ... }` |\n| `processCellForFill` | `(params: { value: unknown; delta: { rows: number; cols: number }; rowIndex: number; col...` |\n| `processCellForClipboard` | `(params: { ... }) => unknown` |\n| `includeCollapsedRows` | `(rowIndex: number) => boolean` |\n| `clipboardHtml` | `(params: { rects: ReadonlyArray<{ ... }>; text: string; }) => string \\| null \\| undefined` |\n| `processCellFromClipboard` | `(params: { ... }) => unknown` |\n| `initialSorting` | `Array<{ id: string; desc: boolean }>` |\n| `initialAdvancedFilter` | `GridPredicateExpr \\| null` |\n| `getRowId` | `(row: TData, index: number) => string` |\n| `rowClass` | `(ctx: { row: TData; rowIndex: number; }) => string \\| ReadonlyArray<string> \\| Record<str...` |\n| `notes` | `Record<string, Record<string, string>>` |\n| `conditionalFormats` | `ReadonlyArray<ConditionalFormat<TData>>` |\n| `rowPlaceholder` | `(row: TData, rowIndex: number) => \"loading\" \\| \"failed\" \\| null` |\n| `rowModel` | `GridRowModel<TData>` |\n| `pivotResultColumns` | `Array<ColumnDef<TFeatures, TData>> \\| null` |\n| `rowSelectionModel` | `{ ... }` |\n| `isDetailRow` | `(row: TData, rowIndex: number) => boolean` |\n| `isDetailOpen` | `(row: TData) => boolean` |\n| `hasDetail` | `(row: TData) => boolean` |\n| `serverGroup` | `{ ... }` |\n| `serverFilterValues` | `(columnId: string) => Promise<string[]>` |\n| `pinnedTopRows` | `ReadonlyArray<TData>` |\n| `mergedCells` | `ReadonlyArray<{ ... }>` |\n| `pinnedBottomRows` | `ReadonlyArray<TData>` |\n| `columnOrder` | `ReadonlyArray<string>` |\n\n### Events (26)\n\n`detail` is the callback's argument. The one callback that takes two carries\nan object keyed by its parameter names.\n\n| Event | From | `detail` |\n| --- | --- | --- |\n| `pivotmodechange` | `onPivotModeChange` | `on` |\n| `expandedchange` | `onExpandedChange` | `expanded` |\n| `paginationchange` | `onPaginationChange` | `pagination` |\n| `columnresize` | `onColumnResize` | `event` |\n| `rowresize` | `onRowResize` | `event` |\n| `pasteclipboard` | `onPasteClipboard` | `payload` |\n| `apiready` | `onApiReady` | `api` |\n| `rowselectionchange` | `onRowSelectionChange` | `{ selection, rows }` |\n| `cellselectionchange` | `onCellSelectionChange` | `ranges` |\n| `sortingchange` | `onSortingChange` | `sorting` |\n| `advancedfilterchange` | `onAdvancedFilterChange` | `expr` |\n| `filterschange` | `onFiltersChange` | `filters` |\n| `notechange` | `onNoteChange` | `event` |\n| `cellvaluechange` | `onCellValueChange` | `event` |\n| `activecellchange` | `onActiveCellChange` | `cell` |\n| `cellclick` | `onCellClick` | `event` |\n| `rowclick` | `onRowClick` | `row` - published detail is the row, not the whole click event |\n| `celldoubleclick` | `onCellDoubleClick` | `event` |\n| `rowdoubleclick` | `onRowDoubleClick` | `event` |\n| `scrollbottomreached` | `onScrollBottomReached` | `event` |\n| `visiblerangechange` | `onVisibleRangeChange` | `range` |\n| `retryrow` | `onRetryRow` | `{ row, rowIndex }` |\n| `detailtoggle` | `onDetailToggle` | `{ row, rowIndex }` |\n| `columnorderchange` | `onColumnOrderChange` | `order` |\n| `rowdragend` | `onRowDragEnd` | `event` |\n| `rowdrop` | `onRowDrop` | `event` |\n\nPlus one alias kept from before the surface was generated:\n\n| Event | From | `detail` |\n| --- | --- | --- |\n| `selectionchange` | `onRowSelectionChange` | `rows` - published alias of rowselectionchange, detail is the selected rows |\n\n### Not exposed (3)\n\n| Prop | Why |\n| --- | --- |\n| `selectable` | element-level shorthand for row selection - see GridBody.svelte |\n| `icons` | Svelte snippets keyed by icon name - cannot cross the custom-element boundary |\n| `renderDetailRow` | Svelte snippet - cannot cross the custom-element boundary |\n\n<!-- END generated reference -->\n\n\n## The imperative API\n\nThe api surface `apiready` hands you.\n\n<div data-docs-demo=\"90-selection-api\" data-height=\"460\"></div>\n\n`onApiReady` fires once with the grid's api. Because a listener bound after\nmount would miss it, the handle is also parked on the element:\n\n```js\ngrid.addEventListener('apiready', (e) => e.detail.exportCsv())\n// or, at any time later:\ngrid.api.exportCsv()\n```\n\n\n## See also\n\n- [Quick start](./quick-start.md) - the CDN path, no build step.\n- [Limitations](./limitations.md) - what cannot cross the boundary.\n- [Shadow DOM](./shadow-dom.md) - `<sv-grid-shadow>` and style isolation.\n"
6054
6069
  },
6055
6070
  {
6056
6071
  "slug": "help/web-components/sv-sheet",
@@ -6379,7 +6394,7 @@ export const docs = [
6379
6394
  "path": "docs/reference/SvGrid.md",
6380
6395
  "title": "<SvGrid> reference",
6381
6396
  "section": "API reference",
6382
- "markdown": "# `<SvGrid>` reference\n\nThe render component. One `<SvGrid>` element per grid instance.\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showPagination={true}\n pageSize={25}\n onApiReady={(api) => (gridApi = api)}\n/>\n```\n\n## Required props\n\n| Prop | Type | Notes |\n| ---------- | ------------------------------------------ | ---------------------------------------------------------------------- |\n| `data` | `ReadonlyArray<TData>` | The row source. Re-rendering on `data` reference change is automatic. |\n| `columns` | `Array<ColumnDef<TFeatures, TData>>` | See [ColumnDef reference](./ColumnDef.md). |\n| `features` | `TFeatures` | Build with `tableFeatures({...})`. See [features reference](./features.md). |\n\n## Data state\n\n| Prop | Type | Default | Notes |\n| -------------- | --------------------- | -------------------- | ----------------------------------------------------------- |\n| `loading` | `boolean` | `false` | Shows a built-in loading overlay. |\n| `error` | `string \\| null` | `null` | Shows an error banner inside the shell. |\n| `emptyMessage` | `string` | `\"No data\"` | Replaces the empty-state copy. |\n\n## Layout\n\n| Prop | Type | Default | Notes |\n| -------------------- | -------------------- | ----------- | ---------------------------------------------------------------------- |\n| `containerHeight` | `number \\| string` | `520` | Pixels (number) or any CSS height (string). Use `'100%'` inside a flex parent. |\n| `rowHeight` | `number \\| (i) => number` | `30` | In pixels. Drives virtualizer math when virtualization is on. Pass a function for per-row heights. Not a CSS token - the virtualizer needs a number, so it is written as an inline style per row. |\n| `columnWidth` | `number` | `140` | Default for columns without an explicit `width`. |\n| `fitColumns` | `boolean` | `false` | Scale columns proportionally to fill the viewport; residue absorbed in the last column. Shrinks down to 85% of natural widths; beyond that the user gets a horizontal scrollbar. |\n| `columnResize` | `boolean` | `false` | Drag a column edge to change its width, or focus the handle and use Left/Right (Shift = 1px). **Off by default**, so `width` is the width you asked for. Grid-wide, not per-column. `autosizeColumn` / `fitColumns` work either way. Handles load on demand. |\n| `rowResize` | `boolean` | `false` | Drag a row's bottom edge to change its height, or focus the grip and use Up/Down (Shift = 1px). The grid remembers the heights, so no `rowHeight` function is needed. **Turns on `showRowNumbers`**, since the grip lives in the row header; an explicit `showRowNumbers={false}` wins and the grip falls back to the row's first cell. Ignored under `autoRowHeight`. Grips load on demand. |\n| `showRowNumbers` | `boolean` | `false` | Leading 1-based row-number column. |\n| `rowNumberWidth` | `number` | `56` | Width (px) of the row-number column. Default fits up to \"99,999\"; bump it for six-figure row counts so the largest number stays fully visible at the bottom of a long scroll. |\n| `initialColumnPinning` | `{ left?, right? }`| - | Seed left/right pinning at mount. |\n\n## Icons\n\n| Prop | Type | Notes |\n| ------- | --------------------------------------- | ----- |\n| `icons` | `Partial<Record<GridIconName, Snippet>>` | Replace any of the glyphs the grid draws for its own chrome, keyed by name. Names you leave out keep their built-in glyph, so a partial map is all you ever need. Your markup is wrapped in the grid icon box, so it inherits the size of the glyph it replaced and the rotation an expander applies when it opens; use `currentColor` so it picks up hover / muted / accent states. See [Icons](../help/icons.md) for the full catalogue. |\n\n## Virtualization\n\n| Prop | Type | Default | Notes |\n| ---------------------- | --------- | ------- | -------------------------------------------------- |\n| `virtualization` | `boolean` | `true` | Row virtualization. |\n| `columnVirtualization` | `boolean` | `true` | Column virtualization. Disable for sticky-column pinning. |\n| `overscan` | `number` | `8` | Rows kept rendered above + below the viewport. |\n| `columnOverscan` | `number` | `3` | Columns kept rendered left + right of the viewport. |\n\n## Filter UI\n\n| Prop | Type | Default | Notes |\n| ------------------- | ------------------------------------------ | --------- | ---------------------------------------------------------------------- |\n| `filterMode` | `'menu' \\| 'row' \\| 'global' \\| 'none'` | `'menu'` | Umbrella prop. Overridden per-surface by the three below. |\n| `showGlobalFilter` | `boolean` | derived | Show the global search input. |\n| `showColumnFilters` | `boolean` | derived | Show the column-menu filter funnel. |\n| `showFilterRow` | `boolean` | derived | Show the per-column filter row under the header. |\n| `showFilterMenu` | `boolean` | derived | (Legacy alias for `showColumnFilters`.) |\n| `externalFilter` | `boolean` | `false` | Grid records filter UI state but does NOT filter rows. Pair with `onFiltersChange`. |\n\n## Selection\n\n| Prop | Type | Default | Notes |\n| -------------------- | ------------------------------------------ | ------- | ---------------------------------------------------- |\n| `selectionMode` | `'both' \\| 'row' \\| 'cell' \\| 'none'` | `'both'`| Umbrella prop. Overridden per-surface by the two below. |\n| `showRowSelection` | `boolean` | derived | Checkbox column. |\n| `enableCellSelection`| `boolean` | derived | Click-and-drag range selection. |\n\n## Editing\n\n| Prop | Type | Default | Notes |\n| --------------------- | --------- | ------- | ------------------------------------------------------ |\n| `enableInlineEditing` | `boolean` | `false` | Per-column `editorType` still required for a column to be editable. |\n| `enableRowSummaries` | `boolean` | `false` | Footer row aggregating every filtered row. Alias: `summary`. |\n\n## Sort\n\n| Prop | Type | Default | Notes |\n| -------------- | --------- | ------- | -------------------------------------------------------------- |\n| `externalSort` | `boolean` | `false` | Grid records sort state but does NOT re-order rows. Pair with `onSortingChange`. |\n\n## Pagination\n\n| Prop | Type | Default | Notes |\n| ---------------- | --------- | ------- | -------------------------------------------------------------- |\n| `showPagination` | `boolean` | `false` | Show the footer pager. |\n| `pageSize` | `number` | `10` | Initial page size. |\n\n## Grouping\n\n| Prop | Type | Default | Notes |\n| ---------------------- | -------------------------- | ------- | -------------------------------- |\n| `showGroupingControls` | `boolean` | `false` | Show the group-by toolbar. |\n| `groupBy` | `ReadonlyArray<string>` | `[]` | Column ids to group by, outermost first. Re-applies when the prop changes; ignored when `treeData` is set. |\n| `expanded` | `Record<string, boolean>` | `{}` | Which group / tree rows are open, keyed by row id. Seeds the state and re-applies on change. |\n\n## Callbacks\n\nAll callbacks are optional. None of them are required for the grid to\nfunction - they exist for parents that want to observe or override.\n\n### `onApiReady(api)`\n\nFires once after mount with the [`SvGridApi`](./SvGridApi.md) for\nimperative operations.\n\n### `onSortingChange(sorting)`\n\n```ts\nonSortingChange?: (sorting: Array<{ id: string; desc: boolean }>) => void\n```\n\nFires when the user clicks a sort header. With `externalSort={true}`,\nthis is your hook to re-fetch / re-order the source data.\n\n### `onFiltersChange(filters)`\n\n```ts\nonFiltersChange?: (filters: {\n global: string\n columns: Array<{\n id: string\n operator: 'contains' | 'equals' | 'startsWith' | 'greaterThan' | 'lessThan' | 'isBlank'\n value: string\n selectedValues?: Array<string>\n }>\n}) => void\n```\n\nFires when the global filter, any column operator filter, or any facet\nchecklist changes. With `externalFilter={true}`, this is your hook to\nre-fetch.\n\n### `onRowSelectionChange(selection, rows)`\n\n```ts\nonRowSelectionChange?: (\n selection: Record<string, boolean>,\n rows: TData[],\n) => void\n```\n\n`selection` is keyed by row index; `rows` is the materialised array of\nselected rows.\n\n### `onCellValueChange(event)`\n\n```ts\nonCellValueChange?: (event: {\n rowIndex: number\n columnId: string\n oldValue: unknown\n newValue: unknown\n row: TData\n}) => void\n```\n\nFires when an inline edit **commits**. This is the editor's commit path\nonly - the programmatic `api.setCellValue()` writes the value without\nfiring it. Use this for cascading recomputes (line totals, derived\ncolumns) and for sending edits to a server. See\n[Saving values](../help/editing/saving-values.md).\n\n### `onExpandedChange(expanded)`\n\n```ts\nonExpandedChange?: (expanded: Record<string, boolean>) => void\n```\n\nFires whenever the expanded set changes - a click on a group banner,\n`api.setRowExpanded()`, `api.expandAllGroups()` /\n`api.collapseAllGroups()`. Receives the full next map, so it can be\nwritten straight back into the `expanded` prop for a controlled setup\nwithout looping. Group row ids look like\n`group_department_Engineering`; tree rows use the engine row id.\n\n### `onActiveCellChange(cell)`\n\n```ts\nonActiveCellChange?: (cell: {\n rowIndex: number\n colIndex: number\n columnId: string\n}) => void\n```\n\nFires every time the active cell changes - click, keyboard move,\nTab, Page Up/Down. Toolbar / ribbon UIs use this to stay synced with\nthe grid's selection without polling.\n\n## See also\n\n- [`SvGridApi`](./SvGridApi.md) - the imperative API exposed via `onApiReady`\n- [`ColumnDef`](./ColumnDef.md) - column-definition shape\n- [features](./features.md) - the feature registry\n- [Enterprise extensions](./enterprise.md) - what `installEnterprise(api)` adds\n"
6397
+ "markdown": "# `<SvGrid>` reference\n\nThe render component. One `<SvGrid>` element per grid instance.\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showPagination={true}\n pageSize={25}\n onApiReady={(api) => (gridApi = api)}\n/>\n```\n\n## Required props\n\n| Prop | Type | Notes |\n| ---------- | ------------------------------------------ | ---------------------------------------------------------------------- |\n| `data` | `ReadonlyArray<TData>` | The row source. Re-rendering on `data` reference change is automatic. |\n| `columns` | `Array<ColumnDef<TFeatures, TData>>` | See [ColumnDef reference](./ColumnDef.md). |\n| `features` | `TFeatures` | Build with `tableFeatures({...})`. See [features reference](./features.md). |\n\n## Data state\n\n| Prop | Type | Default | Notes |\n| -------------- | --------------------- | -------------------- | ----------------------------------------------------------- |\n| `loading` | `boolean` | `false` | Shows a built-in loading overlay. |\n| `error` | `string \\| null` | `null` | Shows an error banner inside the shell. |\n| `emptyMessage` | `string` | `\"No data\"` | Replaces the empty-state copy. |\n\n## Layout\n\n| Prop | Type | Default | Notes |\n| -------------------- | -------------------- | ----------- | ---------------------------------------------------------------------- |\n| `containerHeight` | `number \\| string` | `520` | Pixels (number) or any CSS height (string). Use `'100%'` inside a flex parent. |\n| `rowHeight` | `number \\| (i) => number` | `30` | In pixels. Drives virtualizer math when virtualization is on. Pass a function for per-row heights. Not a CSS token - the virtualizer needs a number, so it is written as an inline style per row. |\n| `columnWidth` | `number` | `140` | Default for columns without an explicit `width`. |\n| `fitColumns` | `boolean` | `false` | Scale columns proportionally to fill the viewport; residue absorbed in the last column. Shrinks down to 85% of natural widths; beyond that the user gets a horizontal scrollbar. |\n| `columnResize` | `boolean` | `false` | Drag a column edge to change its width, or focus the handle and use Left/Right (Shift = 1px). **Off by default**, so `width` is the width you asked for. Grid-wide, not per-column. `autosizeColumn` / `fitColumns` work either way. Handles load on demand. |\n| `rowResize` | `boolean` | `false` | Drag a row's bottom edge to change its height, or focus the grip and use Up/Down (Shift = 1px). The grid remembers the heights, so no `rowHeight` function is needed. **Turns on `showRowNumbers`**, since the grip lives in the row header; an explicit `showRowNumbers={false}` wins and the grip falls back to the row's first cell. Ignored under `autoRowHeight`. Grips load on demand. |\n| `showRowNumbers` | `boolean` | `false` | Leading 1-based row-number column. |\n| `rowNumberWidth` | `number` | `56` | Width (px) of the row-number column. Default fits up to \"99,999\"; bump it for six-figure row counts so the largest number stays fully visible at the bottom of a long scroll. |\n| `initialColumnPinning` | `{ left?, right? }`| - | Seed left/right pinning at mount. |\n\n## Icons\n\n| Prop | Type | Notes |\n| ------- | --------------------------------------- | ----- |\n| `icons` | `Partial<Record<GridIconName, Snippet>>` | Replace any of the glyphs the grid draws for its own chrome, keyed by name. Names you leave out keep their built-in glyph, so a partial map is all you ever need. Your markup is wrapped in the grid icon box, so it inherits the size of the glyph it replaced and the rotation an expander applies when it opens; use `currentColor` so it picks up hover / muted / accent states. See [Icons](../help/icons.md) for the full catalogue. |\n\n## Virtualization\n\n| Prop | Type | Default | Notes |\n| ---------------------- | --------- | ------- | -------------------------------------------------- |\n| `virtualization` | `boolean` | `true` | Row virtualization. |\n| `columnVirtualization` | `boolean` | `true` | Column virtualization. Disable for sticky-column pinning. |\n| `overscan` | `number` | `8` | Rows kept rendered above + below the viewport. |\n| `columnOverscan` | `number` | `3` | Columns always kept rendered ahead of a horizontal scroll (one behind). The columns move three at a time, so up to `columnOverscan + 2` are rendered ahead right after a move. |\n\n## Filter UI\n\n| Prop | Type | Default | Notes |\n| ------------------- | ------------------------------------------ | --------- | ---------------------------------------------------------------------- |\n| `filterMode` | `'menu' \\| 'row' \\| 'global' \\| 'none'` | `'menu'` | Umbrella prop. Overridden per-surface by the three below. |\n| `showGlobalFilter` | `boolean` | derived | Show the global search input. |\n| `showColumnFilters` | `boolean` | derived | Show the column-menu filter funnel. |\n| `showFilterRow` | `boolean` | derived | Show the per-column filter row under the header. |\n| `showFilterMenu` | `boolean` | derived | (Legacy alias for `showColumnFilters`.) |\n| `externalFilter` | `boolean` | `false` | Grid records filter UI state but does NOT filter rows. Pair with `onFiltersChange`. |\n\n## Selection\n\n| Prop | Type | Default | Notes |\n| -------------------- | ------------------------------------------ | ------- | ---------------------------------------------------- |\n| `selectionMode` | `'both' \\| 'row' \\| 'cell' \\| 'none'` | `'both'`| Umbrella prop. Overridden per-surface by the two below. |\n| `showRowSelection` | `boolean` | derived | Checkbox column. |\n| `enableCellSelection`| `boolean` | derived | Click-and-drag range selection. |\n\n## Editing\n\n| Prop | Type | Default | Notes |\n| --------------------- | --------- | ------- | ------------------------------------------------------ |\n| `enableInlineEditing` | `boolean` | `false` | Per-column `editorType` still required for a column to be editable. |\n| `enableRowSummaries` | `boolean` | `false` | Footer row aggregating every filtered row. Alias: `summary`. |\n\n## Sort\n\n| Prop | Type | Default | Notes |\n| -------------- | --------- | ------- | -------------------------------------------------------------- |\n| `externalSort` | `boolean` | `false` | Grid records sort state but does NOT re-order rows. Pair with `onSortingChange`. |\n\n## Pagination\n\n| Prop | Type | Default | Notes |\n| ---------------- | --------- | ------- | -------------------------------------------------------------- |\n| `showPagination` | `boolean` | `false` | Show the footer pager. |\n| `pageSize` | `number` | `10` | Initial page size. |\n\n## Grouping\n\n| Prop | Type | Default | Notes |\n| ---------------------- | -------------------------- | ------- | -------------------------------- |\n| `showGroupingControls` | `boolean` | `false` | Show the group-by toolbar. |\n| `groupBy` | `ReadonlyArray<string>` | `[]` | Column ids to group by, outermost first. Re-applies when the prop changes; ignored when `treeData` is set. |\n| `expanded` | `Record<string, boolean>` | `{}` | Which group / tree rows are open, keyed by row id. Seeds the state and re-applies on change. |\n\n## Callbacks\n\nAll callbacks are optional. None of them are required for the grid to\nfunction - they exist for parents that want to observe or override.\n\n### `onApiReady(api)`\n\nFires once after mount with the [`SvGridApi`](./SvGridApi.md) for\nimperative operations.\n\n### `onSortingChange(sorting)`\n\n```ts\nonSortingChange?: (sorting: Array<{ id: string; desc: boolean }>) => void\n```\n\nFires when the user clicks a sort header. With `externalSort={true}`,\nthis is your hook to re-fetch / re-order the source data.\n\n### `onFiltersChange(filters)`\n\n```ts\nonFiltersChange?: (filters: {\n global: string\n columns: Array<{\n id: string\n operator: 'contains' | 'equals' | 'startsWith' | 'greaterThan' | 'lessThan' | 'isBlank'\n value: string\n selectedValues?: Array<string>\n }>\n}) => void\n```\n\nFires when the global filter, any column operator filter, or any facet\nchecklist changes. With `externalFilter={true}`, this is your hook to\nre-fetch.\n\n### `onRowSelectionChange(selection, rows)`\n\n```ts\nonRowSelectionChange?: (\n selection: Record<string, boolean>,\n rows: TData[],\n) => void\n```\n\n`selection` is keyed by row index; `rows` is the materialised array of\nselected rows.\n\n### `onCellValueChange(event)`\n\n```ts\nonCellValueChange?: (event: {\n rowIndex: number\n columnId: string\n oldValue: unknown\n newValue: unknown\n row: TData\n}) => void\n```\n\nFires when an inline edit **commits**. This is the editor's commit path\nonly - the programmatic `api.setCellValue()` writes the value without\nfiring it. Use this for cascading recomputes (line totals, derived\ncolumns) and for sending edits to a server. See\n[Saving values](../help/editing/saving-values.md).\n\n### `onExpandedChange(expanded)`\n\n```ts\nonExpandedChange?: (expanded: Record<string, boolean>) => void\n```\n\nFires whenever the expanded set changes - a click on a group banner,\n`api.setRowExpanded()`, `api.expandAllGroups()` /\n`api.collapseAllGroups()`. Receives the full next map, so it can be\nwritten straight back into the `expanded` prop for a controlled setup\nwithout looping. Group row ids look like\n`group_department_Engineering`; tree rows use the engine row id.\n\n### `onActiveCellChange(cell)`\n\n```ts\nonActiveCellChange?: (cell: {\n rowIndex: number\n colIndex: number\n columnId: string\n}) => void\n```\n\nFires every time the active cell changes - click, keyboard move,\nTab, Page Up/Down. Toolbar / ribbon UIs use this to stay synced with\nthe grid's selection without polling.\n\n## See also\n\n- [`SvGridApi`](./SvGridApi.md) - the imperative API exposed via `onApiReady`\n- [`ColumnDef`](./ColumnDef.md) - column-definition shape\n- [features](./features.md) - the feature registry\n- [Enterprise extensions](./enterprise.md) - what `installEnterprise(api)` adds\n"
6383
6398
  },
6384
6399
  {
6385
6400
  "slug": "reference/SvGridApi",
@@ -6974,7 +6989,7 @@ export const docs = [
6974
6989
  "path": "docs/_data/comparisons/svar-svelte-datagrid.json",
6975
6990
  "title": "SvGrid vs SVAR Svelte DataGrid",
6976
6991
  "section": "Comparisons",
6977
- "markdown": "# SvGrid vs SVAR Svelte DataGrid\n\nCanonical URL: https://svgrid.com/compare/svar-svelte-datagrid/\nFacts verified: 2026-09-12\nCompetitor package: wx-svelte-grid\nCompetitor site: https://svar.dev/svelte/datagrid/\n\nSVAR Svelte DataGrid (wx-svelte-grid) is a Svelte-native, MIT-licensed data grid from XB Software, the team behind Webix, with React and Vue versions of the same grid.\n\n## Verdict\n\nSVAR is the closest genuinely Svelte-native rival: both grids are MIT and both target Svelte 5. SvGrid has the wider capability surface (grouping with aggregation, master/detail, Excel-style range editing with a fill handle, integrated charts, a headless core, in-grid AI helpers and an MCP server); SVAR is the smaller grid with print in the free package, React and Vue versions, and the Webix team behind it.\n\nMigration guide: moving from SVAR Svelte DataGrid to SvGrid: https://svgrid.com/docs/help/migrating-from-svar-svelte-datagrid/\n\n## At a glance\n\n| Fact | SvGrid | SVAR Svelte DataGrid | Checked |\n| --- | --- | --- | --- |\n| Latest version | 3.0.3 | 2.7.0 | 12 Sep 2026 |\n| Licence field on npm | MIT | MIT | 12 Sep 2026 |\n| Last published | 11 Sep 2026 | 3 Jun 2026 | 12 Sep 2026 |\n| Svelte 5 support | Native (Svelte 5 runes) | Svelte package, no peer range declared | 12 Sep 2026 |\n| npm downloads, 30 days to 10 Sep 2026 | 16,900 | 62,800 | 12 Sep 2026 |\n| Bundle, minified + gzip | 97.6 KB JS + 10.5 KB CSS | 51.5 KB JS, no separate stylesheet, svelte external | 12 Sep 2026 |\n| Engine alone, minified + gzip | 2.7 KB JS, no separate stylesheet (`createSvGrid` from `@svgrid/grid/core`) | the row above; the package is the engine | 22 Sep 2026 |\n| Frameworks | Svelte 5 | Svelte, React, Vue | 12 Sep 2026 |\n| Pricing | MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise | The DataGrid is MIT and free for commercial use with no paid tier; svar.dev sells PRO editions of its Gantt, Calendar and Kanban components and offers paid consulting and custom development, with no price list for the grid | 12 Sep 2026 |\n\nBundle sizes: each package built alone with Vite in library mode, minified, gzip level 9, Svelte kept external where a package uses it (it is a peer dependency). SvGrid 3.0.4 measured 22 Sep 2026 with packages/grid/scripts/measure-size.mjs; wx-svelte-grid 2.7.0 entry `Grid component` measured 12 Sep 2026 with packages/grid/scripts/measure-competitor-bundles.mjs.\n\n## Overview\n\nSVAR DataGrid (npm: wx-svelte-grid) is part of the SVAR suite from XB Software, the makers of Webix, and is one of the few grids built for Svelte rather than wrapped from another framework. Its docs list in-cell editing, header and advanced filtering, single and multi-column sorting, tree data, virtual scrolling for rows and columns, columns pinned to the left, column and row spans in the header and footer, auto-sized rows, undo and redo, a context menu, row reordering, CSV export, a print API and a REST data provider, all under MIT.\n\nSvGrid overlaps on the Svelte-native angle and reaches further: row grouping with aggregation, master/detail, Excel-style cell-range selection with clipboard and a fill handle, pinned rows and left and right column pinning, integrated charts, a server-side row model, a headless engine, in-grid AI helpers and the @svgrid/mcp server, all in the MIT package. Excel, PDF and print output, import, pivot tables and the Kanban and Scheduler views of the grid live in the paid @svgrid/enterprise pack, whereas SVAR keeps its grid entirely free and sells PRO editions of its Gantt, Calendar and Kanban instead.\n\n## Looking for a SVAR Svelte DataGrid alternative for Svelte?\n\nThe closest comparison on this list, since both are Svelte-native and both are MIT, so licensing does not decide it. The differences are depth and direction: SvGrid ships a larger feature surface, a UI component suite, in-grid AI helpers and an MCP server, while SVAR spans Svelte, React and Vue and keeps a smaller core. The At a glance table above has both measured bundle sizes; if a compact MIT grid with sorting, filtering, editing, tree data and CSV export is all you need, SVAR is a perfectly good answer.\n\n## What they have in common\n\n- Purpose-built for Svelte 5, not a cross-framework wrapper.\n- MIT-licensed and free for commercial use.\n- Sorting, filtering, in-cell editing, virtualization on rows and columns, tree data.\n- Auto-sized row heights, undo and redo, a context menu and drag-and-drop row reordering.\n- Themeable through CSS variables with light and dark themes, localization, WAI-ARIA markup and keyboard navigation.\n\n## Where SvGrid is stronger\n\n- Row grouping with aggregation and a drag-and-drop group panel; SVAR's docs list neither.\n- Master/detail and expandable detail rows; SVAR offers tree data only.\n- Excel-style cell-range selection with clipboard copy and paste and a fill handle; SVAR selects rows.\n- Pinned top and bottom rows, and columns pinned left and right; SVAR pins columns to the left only.\n- Integrated charts with no dependency, plus a chart wizard.\n- A server-side row model with cursor pagination, GraphQL, websocket and optimistic updates, beyond a REST data provider.\n- A headless engine (createSvGrid) you can compose, in addition to the render component.\n- In-grid AI helpers free in @svgrid/grid, and @svgrid/mcp with llms.txt so assistants answer and write code accurately.\n- A paid pack with Excel, PDF and styled-HTML export, print, pivot tables, Excel and CSV import and support. CSV, TSV and JSON export are free in the grid.\n\n## Where SVAR Svelte DataGrid is stronger\n\n- The whole grid is free, print API included; SvGrid puts print and Excel and PDF export in the paid pack. Both export CSV for free.\n- The same grid for React and Vue as well as Svelte; SvGrid is Svelte-only by design.\n- A smaller bundle: the At a glance table has both measured numbers.\n- XB Software and Webix behind it, a team that has built data grids for a long time.\n- Part of the wider SVAR suite, with paid consulting and custom development on offer.\n\n## Feature by feature\n\nYes: shipped in the package. Partial: needs extra work or a companion package. Paid tier: only in a paid edition. No: not available. n/a: does not apply.\n\n| Feature | SvGrid | SVAR Svelte DataGrid |\n| --- | --- | --- |\n| Svelte 5 native | Yes | Yes - Svelte 5 compatible |\n| Licence | Yes - MIT core, paid enterprise pack | Yes - MIT, the grid is entirely free |\n| Headless engine | Yes - createSvGrid | No - component only |\n| Excel-style filter menu | Yes - built in | Yes - header filters plus the SVAR Filter component |\n| Cell-range selection and clipboard | Yes - built in | No - single and multiple row selection |\n| Fill handle | Yes | No - not in the feature list |\n| Row grouping with aggregation | Yes - with a group panel | No - not in the feature list |\n| Master/detail rows | Yes | No - tree data only |\n| Tree data | Yes | Yes |\n| Pinned columns | Yes - left and right | Partial - to the left |\n| Pinned rows (top and bottom) | Yes | No - not in the feature list |\n| Header and footer column spans | Yes | Yes |\n| Auto-sized row height | Yes - autoRowHeight | Yes |\n| Virtualization (rows and columns) | Yes | Yes |\n| Undo and redo, context menu, row drag | Yes | Yes |\n| Integrated charts | Yes - no dependency, plus a chart wizard | No - not in the feature list |\n| Server-side data, flat (sort, filter, paging, infinite scroll) | Yes - createServerDataSource: page or infinite block scroll, cursor, GraphQL, websocket | Partial - RestDataProvider and dynamic loading while scrolling |\n| Server-side row model (lazy grouping, tree, pivot, transactions, select-all across unloaded rows) | Paid tier - @svgrid/enterprise | No - not in the feature list |\n| CSV export | Yes - free, with TSV and JSON | Yes - free |\n| Excel and PDF export | Paid tier - @svgrid/enterprise | No - CSV only in the feature list |\n| Print | Paid tier - @svgrid/enterprise | Yes - print API, free |\n| Pivot tables | Paid tier - @svgrid/enterprise | No - not in the SVAR Svelte product list |\n| React and Vue versions | No - Svelte only | Yes |\n| In-grid AI helpers and MCP server | Yes - helpers free in @svgrid/grid; @svgrid/mcp, MIT | No - not mentioned in the docs |\n| Paid support | Yes - with @svgrid/enterprise, per developer per year | Partial - paid consulting and custom development; no support plan for the grid listed |\n\n## Measured performance\n\nMeasured 12 Sep 2026 with the harness in the repository (pnpm bench:compare): 100,000 rows, fastest of 7 samples per operation, each grid in a fresh page, Chromium through Playwright, headless, on win32 10.0.26200 x64, Intel(R) Core(TM) Ultra 7 255U; a developer workstation, not a dedicated bench rig. Versions: @svgrid/grid 3.0.3, wx-svelte-grid 2.7.0. Lower is better.\n\n| Operation | SvGrid | SVAR Svelte DataGrid |\n| --- | --- | --- |\n| Mount | 75.5 ms | 11.4 ms |\n| Sort, text column | 149 ms | 187.2 ms |\n| Sort, numeric column | 115.7 ms | 165.1 ms |\n| Filter, one column (indicative only) | 99.6 ms | 32.6 ms |\n| Scroll, p95 frame | 34.6 ms | 34.8 ms |\n| Rows kept in the DOM (virtualization on) | 27 | 19 |\n\nThe filter row is not like for like: each grid is driven through its own single-column filter API, and the amount of work differs. The comparison guide explains the method, what is left out and why.\n\n## When to choose SvGrid\n\n- You need grouping with aggregation, master/detail, Excel-style range editing or pivot tables, which SVAR's docs do not list.\n- You want a headless engine plus a render component, not a component-only API.\n- You want integrated charts and a full server-side row model in one library.\n- You want in-grid AI helpers, an MCP server that checks generated code, and a paid support tier.\n\n## When to choose SVAR Svelte DataGrid\n\n- You want a fully free grid with print included and no paid tier at all.\n- You ship to React and Vue as well as Svelte and want one grid across them.\n- A smaller bundle matters more than the features above.\n- You are already adopting the wider SVAR suite: Gantt, Calendar, Kanban, File Manager.\n\n## Migration effort\n\nEstimated effort: 2-4 hours per grid.\n\nWhat you give up:\n\n- Free printing: SVAR's print API is in the MIT grid, SvGrid's print view is in @svgrid/enterprise.\n- The React and Vue versions of the same grid.\n- A smaller bundle, by the measurements in the At a glance table.\n- The SVAR suite around the grid: Gantt, Calendar, Kanban and File Manager share its core.\n\nMigration guide: moving from SVAR Svelte DataGrid to SvGrid: https://svgrid.com/docs/help/migrating-from-svar-svelte-datagrid/\n\n## Live example\n\nGrouping Aggregation: open it in the gallery for the source and the full-size version. https://svgrid.com/demos/07-grouping-aggregation/\n\n## The bottom line\n\nBoth grids are Svelte-5-native and MIT, so this is not a licensing call. On capability SvGrid is the more complete grid: grouping, master/detail, range editing with a fill handle, charts, a server-side row model, a headless core, AI helpers and an MCP server. SVAR is the smaller, fully free, multi-framework option from the Webix team, and it prints for free where SvGrid charges for print and Excel and PDF export. Choose SvGrid when you need the deeper feature set or support; choose SVAR when a compact free grid covers your needs or you also ship React and Vue.\n\n## Frequently asked questions\n\n### SvGrid vs SVAR Svelte DataGrid: what is the difference?\n\nBoth are MIT-licensed, Svelte-5-native grids. SvGrid is the broader product: row grouping with aggregation, master/detail, Excel-style range selection with a fill handle, integrated charts, a server-side row model, a headless core, in-grid AI helpers and an MCP server. SVAR is a smaller, fully free grid with React and Vue versions from XB Software, with CSV export and a print API included.\n\n### Are SvGrid and SVAR both free?\n\nBoth are free under MIT, including commercial use. SVAR keeps the whole grid free, CSV export and print included, and sells PRO editions of its Gantt, Calendar and Kanban. SvGrid's grid is free too, including CSV, TSV and JSON export, charts and the AI helpers; Excel, PDF and print output, import, pivot tables, the Kanban and Scheduler views and support are in @svgrid/enterprise at $599 per developer per year.\n\n### Are both built for Svelte 5?\n\nYes. SvGrid is written in Svelte 5 runes, and svar.dev states the DataGrid is Svelte 5 compatible. Neither is a cross-framework wrapper, although SVAR also ships React and Vue versions of its grid.\n\n### Which grid is smaller?\n\nSVAR, by the measurements in the At a glance table: both packages were built alone with Vite, minified and gzipped with Svelte external, on the dates shown. SvGrid's extra size buys grouping, master/detail, range editing, charts and the server-side row model; features you never import, such as the date editors, menus and charts, load as separate chunks.\n\n### Does SVAR have row grouping, master/detail or cell-range selection?\n\nNot according to its docs overview on the date we read it, which lists tree data, single and multiple row selection, header and advanced filters, sorting, in-cell editing, columns pinned to the left, CSV export and a print API. SvGrid has all three in the MIT core, and the demos on this page show them.\n\n### How long does a SVAR to SvGrid migration take?\n\nThe migration guide budgets two to four hours per grid: a prop and event rename pass, since both use array-of-object column definitions, plus re-theming through --sg-* tokens.\n\n### Is SvGrid a good SVAR Svelte DataGrid alternative for Svelte?\n\nThe closest comparison on this list, since both are Svelte-native and both are MIT, so licensing does not decide it. The differences are depth and direction: SvGrid ships a larger feature surface, a UI component suite, in-grid AI helpers and an MCP server, while SVAR spans Svelte, React and Vue and keeps a smaller core. The At a glance table above has both measured bundle sizes; if a compact MIT grid with sorting, filtering, editing, tree data and CSV export is all you need, SVAR is a perfectly good answer.\n\n## Related comparisons and docs\n\n- [SvGrid vs svelte-headless-table](https://svgrid.com/compare/svelte-headless-table/)\n- [SvGrid vs TanStack Table (Svelte)](https://svgrid.com/compare/tanstack-table/)\n- [SvGrid vs @vincjo/datatables](https://svgrid.com/compare/vincjo-datatables/)\n- [Comparison: SvGrid vs AG Grid vs TanStack Table](https://svgrid.com/docs/help/comparison/)\n- [Grouping & aggregation](https://svgrid.com/docs/help/grouping-aggregation/)\n- [Master / detail (nested grids)](https://svgrid.com/docs/help/rows/master-detail/)\n- [Build a table from scratch](https://svgrid.com/docs/help/headless/build-a-table/)\n\n## Sources and last verified\n\nEvery claim on this page was last checked on 12 Sep 2026. Versions, licences and download counts come from the npm registry; feature and pricing statements from the pages listed below.\n\n- svar-datagrid: Licensed under MIT; Svelte 5 compatible; React and Vue versions available; lists multi-column sorting, header filters, in-cell editing, tree data, virtual scrolling, columns pinned to the left, context menu, row drag-and-drop, checkbox selection, pagination, hotkeys, WAI-ARIA, print, undo/redo (checked 12 Sep 2026) https://svar.dev/svelte/datagrid/\n- svar-overview: Feature list: in-cell editing, header and advanced filtering, sorting, single and multiple row selection, row reordering, context menu, undo/redo, tree data, pagination, colspans and rowspans in header and footer, collapsible column groups, pinned columns to the left, auto-sizing of rows and columns, localization, themes, export to CSV, print API, RestDataProvider, virtual scrolling for rows and columns, WAI-ARIA, keyboard navigation; no Excel export, row grouping, aggregation, master/detail, charts, AI or MCP (checked 12 Sep 2026) https://docs.svar.dev/svelte/grid/overview/\n- svar-svelte: DataGrid, Filter, Editor, File Manager and Core are MIT; Gantt, Calendar and Kanban have MIT cores with paid PRO editions; no pivot component in the list; no price list on the page (checked 12 Sep 2026) https://svar.dev/svelte/\n- svar-gantt: Offers paid consulting and custom development services; PRO edition pricing is on a separate page (checked 12 Sep 2026) https://svar.dev/svelte/gantt/\n"
6992
+ "markdown": "# SvGrid vs SVAR Svelte DataGrid\n\nCanonical URL: https://svgrid.com/compare/svar-svelte-datagrid/\nFacts verified: 2026-09-12\nCompetitor package: wx-svelte-grid\nCompetitor site: https://svar.dev/svelte/datagrid/\n\nSVAR Svelte DataGrid (wx-svelte-grid) is a Svelte-native, MIT-licensed data grid from XB Software, the team behind Webix, with React and Vue versions of the same grid.\n\n## Verdict\n\nSVAR is the closest genuinely Svelte-native rival: both grids are MIT and both target Svelte 5. SvGrid has the wider capability surface (grouping with aggregation, master/detail, Excel-style range editing with a fill handle, integrated charts, a headless core, in-grid AI helpers and an MCP server); SVAR is the smaller grid with print in the free package, React and Vue versions, and the Webix team behind it.\n\nMigration guide: moving from SVAR Svelte DataGrid to SvGrid: https://svgrid.com/docs/help/migrating-from-svar-svelte-datagrid/\n\n## At a glance\n\n| Fact | SvGrid | SVAR Svelte DataGrid | Checked |\n| --- | --- | --- | --- |\n| Latest version | 3.0.3 | 2.7.0 | 12 Sep 2026 |\n| Licence field on npm | MIT | MIT | 12 Sep 2026 |\n| Last published | 11 Sep 2026 | 3 Jun 2026 | 12 Sep 2026 |\n| Svelte 5 support | Native (Svelte 5 runes) | Svelte package, no peer range declared | 12 Sep 2026 |\n| npm downloads, 30 days to 10 Sep 2026 | 16,900 | 62,800 | 12 Sep 2026 |\n| Bundle, minified + gzip | 97.6 KB JS + 10.5 KB CSS | 51.5 KB JS, no separate stylesheet, svelte external | 12 Sep 2026 |\n| Engine alone, minified + gzip | 2.7 KB JS, no separate stylesheet (`createSvGrid` from `@svgrid/grid/core`) | the row above; the package is the engine | 22 Sep 2026 |\n| Frameworks | Svelte 5 | Svelte, React, Vue | 12 Sep 2026 |\n| Pricing | MIT core; @svgrid/enterprise from $299 per developer per year (Indie, under $1M revenue), $599 for Enterprise | The DataGrid is MIT and free for commercial use with no paid tier; svar.dev sells PRO editions of its Gantt, Calendar and Kanban components and offers paid consulting and custom development, with no price list for the grid | 12 Sep 2026 |\n\nBundle sizes: each package built alone with Vite in library mode, minified, gzip level 9, Svelte kept external where a package uses it (it is a peer dependency). SvGrid 3.0.4 measured 22 Sep 2026 with packages/grid/scripts/measure-size.mjs; wx-svelte-grid 2.7.0 entry `Grid component` measured 12 Sep 2026 with packages/grid/scripts/measure-competitor-bundles.mjs.\n\n## Overview\n\nSVAR DataGrid (npm: wx-svelte-grid) is part of the SVAR suite from XB Software, the makers of Webix, and is one of the few grids built for Svelte rather than wrapped from another framework. Its docs list in-cell editing, header and advanced filtering, single and multi-column sorting, tree data, virtual scrolling for rows and columns, columns pinned to the left, column and row spans in the header and footer, auto-sized rows, undo and redo, a context menu, row reordering, CSV export, a print API and a REST data provider, all under MIT.\n\nSvGrid overlaps on the Svelte-native angle and reaches further: row grouping with aggregation, master/detail, Excel-style cell-range selection with clipboard and a fill handle, pinned rows and left and right column pinning, integrated charts, a server-side row model, a headless engine, in-grid AI helpers and the @svgrid/mcp server, all in the MIT package. Excel, PDF and print output, import, pivot tables and the Kanban and Scheduler views of the grid live in the paid @svgrid/enterprise pack, whereas SVAR keeps its grid entirely free and sells PRO editions of its Gantt, Calendar and Kanban instead.\n\n## Looking for a SVAR Svelte DataGrid alternative for Svelte?\n\nThe closest comparison on this list, since both are Svelte-native and both are MIT, so licensing does not decide it. The differences are depth and direction: SvGrid ships a larger feature surface, a UI component suite, in-grid AI helpers and an MCP server, while SVAR spans Svelte, React and Vue and keeps a smaller core. The At a glance table above has both measured bundle sizes; if a compact MIT grid with sorting, filtering, editing, tree data and CSV export is all you need, SVAR is a perfectly good answer.\n\n## What they have in common\n\n- Purpose-built for Svelte 5, not a cross-framework wrapper.\n- MIT-licensed and free for commercial use.\n- Sorting, filtering, in-cell editing, virtualization on rows and columns, tree data.\n- Auto-sized row heights, undo and redo, a context menu and drag-and-drop row reordering.\n- Themeable through CSS variables with light and dark themes, localization, WAI-ARIA markup and keyboard navigation.\n\n## Where SvGrid is stronger\n\n- Row grouping with aggregation and a drag-and-drop group panel; SVAR's docs list neither.\n- Master/detail and expandable detail rows; SVAR offers tree data only.\n- Excel-style cell-range selection with clipboard copy and paste and a fill handle; SVAR selects rows.\n- Pinned top and bottom rows, and columns pinned left and right; SVAR pins columns to the left only.\n- Integrated charts with no dependency, plus a chart wizard.\n- A server-side row model with cursor pagination, GraphQL, websocket and optimistic updates, beyond a REST data provider.\n- A headless engine (createSvGrid) you can compose, in addition to the render component.\n- In-grid AI helpers free in @svgrid/grid, and @svgrid/mcp with llms.txt so assistants answer and write code accurately.\n- A paid pack with Excel, PDF and styled-HTML export, print, pivot tables, Excel and CSV import and support. CSV, TSV and JSON export are free in the grid.\n\n## Where SVAR Svelte DataGrid is stronger\n\n- The whole grid is free, print API included; SvGrid puts print and Excel and PDF export in the paid pack. Both export CSV for free.\n- The same grid for React and Vue as well as Svelte; SvGrid is Svelte-only by design.\n- A smaller bundle: the At a glance table has both measured numbers.\n- XB Software and Webix behind it, a team that has built data grids for a long time.\n- Part of the wider SVAR suite, with paid consulting and custom development on offer.\n\n## Feature by feature\n\nYes: shipped in the package. Partial: needs extra work or a companion package. Paid tier: only in a paid edition. No: not available. n/a: does not apply.\n\n| Feature | SvGrid | SVAR Svelte DataGrid |\n| --- | --- | --- |\n| Svelte 5 native | Yes | Yes - Svelte 5 compatible |\n| Licence | Yes - MIT core, paid enterprise pack | Yes - MIT, the grid is entirely free |\n| Headless engine | Yes - createSvGrid | No - component only |\n| Excel-style filter menu | Yes - built in | Yes - header filters plus the SVAR Filter component |\n| Cell-range selection and clipboard | Yes - built in | No - single and multiple row selection |\n| Fill handle | Yes | No - not in the feature list |\n| Row grouping with aggregation | Yes - with a group panel | No - not in the feature list |\n| Master/detail rows | Yes | No - tree data only |\n| Tree data | Yes | Yes |\n| Pinned columns | Yes - left and right | Partial - to the left |\n| Pinned rows (top and bottom) | Yes | No - not in the feature list |\n| Header and footer column spans | Yes | Yes |\n| Auto-sized row height | Yes - autoRowHeight | Yes |\n| Virtualization (rows and columns) | Yes | Yes |\n| Undo and redo, context menu, row drag | Yes | Yes |\n| Integrated charts | Yes - no dependency, plus a chart wizard | No - not in the feature list |\n| Server-side data, flat (sort, filter, paging, infinite scroll) | Yes - createServerDataSource: page or infinite block scroll, cursor, GraphQL, websocket | Partial - RestDataProvider and dynamic loading while scrolling |\n| Server-side row model (lazy grouping, tree, pivot, transactions, select-all across unloaded rows) | Paid tier - @svgrid/enterprise | No - not in the feature list |\n| CSV export | Yes - free, with TSV and JSON | Yes - free |\n| Excel and PDF export | Paid tier - @svgrid/enterprise | No - CSV only in the feature list |\n| Print | Paid tier - @svgrid/enterprise | Yes - print API, free |\n| Pivot tables | Paid tier - @svgrid/enterprise | No - not in the SVAR Svelte product list |\n| React and Vue versions | No - Svelte only | Yes |\n| In-grid AI helpers and MCP server | Yes - helpers free in @svgrid/grid; @svgrid/mcp, MIT | No - not mentioned in the docs |\n| Paid support | Yes - with @svgrid/enterprise, per developer per year | Partial - paid consulting and custom development; no support plan for the grid listed |\n\n## Measured performance\n\nMeasured 12 Sep 2026 with the harness in the repository (pnpm bench:compare): 100,000 rows, fastest of 7 samples per operation, each grid in a fresh page, Chromium through Playwright, headless, on win32 10.0.26200 x64, Intel(R) Core(TM) Ultra 7 255U; a developer workstation, not a dedicated bench rig. Versions: @svgrid/grid 3.0.3, wx-svelte-grid 2.7.0. Lower is better.\n\n| Operation | SvGrid | SVAR Svelte DataGrid |\n| --- | --- | --- |\n| Mount | 75.5 ms | 11.4 ms |\n| Sort, text column | 149 ms | 187.2 ms |\n| Sort, numeric column | 115.7 ms | 165.1 ms |\n| Filter, one column (indicative only) | 99.6 ms | 32.6 ms |\n| Scroll, p95 frame | 34.6 ms | 34.8 ms |\n| Rows kept in the DOM (virtualization on) | 27 | 19 |\n\nThe filter row is not like for like: each grid is driven through its own single-column filter API, and the amount of work differs. The comparison guide explains the method, what is left out and why.\n\n## When to choose SvGrid\n\n- You need grouping with aggregation, master/detail, Excel-style range editing or pivot tables, which SVAR's docs do not list.\n- You want a headless engine plus a render component, not a component-only API.\n- You want integrated charts and a full server-side row model in one library.\n- You want in-grid AI helpers, an MCP server that checks generated code, and a paid support tier.\n\n## When to choose SVAR Svelte DataGrid\n\n- You want a fully free grid with print included and no paid tier at all.\n- You ship to React and Vue as well as Svelte and want one grid across them.\n- A smaller bundle matters more than grouping with aggregation, master/detail, range editing and the other features SvGrid adds.\n- You are already adopting the wider SVAR suite: Gantt, Calendar, Kanban, File Manager.\n\n## Migration effort\n\nEstimated effort: 2-4 hours per grid.\n\nWhat you give up:\n\n- Free printing: SVAR's print API is in the MIT grid, SvGrid's print view is in @svgrid/enterprise.\n- The React and Vue versions of the same grid.\n- A smaller bundle, by the measurements in the At a glance table.\n- The SVAR suite around the grid: Gantt, Calendar, Kanban and File Manager share its core.\n\nMigration guide: moving from SVAR Svelte DataGrid to SvGrid: https://svgrid.com/docs/help/migrating-from-svar-svelte-datagrid/\n\n## Live example\n\nGrouping Aggregation: open it in the gallery for the source and the full-size version. https://svgrid.com/demos/07-grouping-aggregation/\n\n## The bottom line\n\nBoth grids are Svelte-5-native and MIT, so this is not a licensing call. On capability SvGrid is the more complete grid: grouping, master/detail, range editing with a fill handle, charts, a server-side row model, a headless core, AI helpers and an MCP server. SVAR is the smaller, fully free, multi-framework option from the Webix team, and it prints for free where SvGrid charges for print and Excel and PDF export. Choose SvGrid when you need the deeper feature set or support; choose SVAR when a compact free grid covers your needs or you also ship React and Vue.\n\n## Frequently asked questions\n\n### SvGrid vs SVAR Svelte DataGrid: what is the difference?\n\nBoth are MIT-licensed, Svelte-5-native grids. SvGrid is the broader product: row grouping with aggregation, master/detail, Excel-style range selection with a fill handle, integrated charts, a server-side row model, a headless core, in-grid AI helpers and an MCP server. SVAR is a smaller, fully free grid with React and Vue versions from XB Software, with CSV export and a print API included.\n\n### Are SvGrid and SVAR both free?\n\nBoth are free under MIT, including commercial use. SVAR keeps the whole grid free, CSV export and print included, and sells PRO editions of its Gantt, Calendar and Kanban. SvGrid's grid is free too, including CSV, TSV and JSON export, charts and the AI helpers; Excel, PDF and print output, import, pivot tables, the Kanban and Scheduler views and support are in @svgrid/enterprise at $599 per developer per year.\n\n### Are both built for Svelte 5?\n\nYes. SvGrid is written in Svelte 5 runes, and svar.dev states the DataGrid is Svelte 5 compatible. Neither is a cross-framework wrapper, although SVAR also ships React and Vue versions of its grid.\n\n### Which grid is smaller?\n\nSVAR, by the measurements in the At a glance table: both packages were built alone with Vite, minified and gzipped with Svelte external, on the dates shown. SvGrid's extra size buys grouping, master/detail, range editing, charts and the server-side row model; features you never import, such as the date editors, menus and charts, load as separate chunks.\n\n### Does SVAR have row grouping, master/detail or cell-range selection?\n\nNot according to its docs overview on the date we read it, which lists tree data, single and multiple row selection, header and advanced filters, sorting, in-cell editing, columns pinned to the left, CSV export and a print API. SvGrid has all three in the MIT core, and the demos on this page show them.\n\n### How long does a SVAR to SvGrid migration take?\n\nThe migration guide budgets two to four hours per grid: a prop and event rename pass, since both use array-of-object column definitions, plus re-theming through --sg-* tokens.\n\n### Is SvGrid a good SVAR Svelte DataGrid alternative for Svelte?\n\nThe closest comparison on this list, since both are Svelte-native and both are MIT, so licensing does not decide it. The differences are depth and direction: SvGrid ships a larger feature surface, a UI component suite, in-grid AI helpers and an MCP server, while SVAR spans Svelte, React and Vue and keeps a smaller core. The At a glance table above has both measured bundle sizes; if a compact MIT grid with sorting, filtering, editing, tree data and CSV export is all you need, SVAR is a perfectly good answer.\n\n## Related comparisons and docs\n\n- [SvGrid vs svelte-headless-table](https://svgrid.com/compare/svelte-headless-table/)\n- [SvGrid vs TanStack Table (Svelte)](https://svgrid.com/compare/tanstack-table/)\n- [SvGrid vs @vincjo/datatables](https://svgrid.com/compare/vincjo-datatables/)\n- [Comparison: SvGrid vs AG Grid vs TanStack Table](https://svgrid.com/docs/help/comparison/)\n- [Grouping & aggregation](https://svgrid.com/docs/help/grouping-aggregation/)\n- [Master / detail (nested grids)](https://svgrid.com/docs/help/rows/master-detail/)\n- [Build a table from scratch](https://svgrid.com/docs/help/headless/build-a-table/)\n\n## Sources and last verified\n\nEvery claim on this page was last checked on 12 Sep 2026. Versions, licences and download counts come from the npm registry; feature and pricing statements from the pages listed below.\n\n- svar-datagrid: Licensed under MIT; Svelte 5 compatible; React and Vue versions available; lists multi-column sorting, header filters, in-cell editing, tree data, virtual scrolling, columns pinned to the left, context menu, row drag-and-drop, checkbox selection, pagination, hotkeys, WAI-ARIA, print, undo/redo (checked 12 Sep 2026) https://svar.dev/svelte/datagrid/\n- svar-overview: Feature list: in-cell editing, header and advanced filtering, sorting, single and multiple row selection, row reordering, context menu, undo/redo, tree data, pagination, colspans and rowspans in header and footer, collapsible column groups, pinned columns to the left, auto-sizing of rows and columns, localization, themes, export to CSV, print API, RestDataProvider, virtual scrolling for rows and columns, WAI-ARIA, keyboard navigation; no Excel export, row grouping, aggregation, master/detail, charts, AI or MCP (checked 12 Sep 2026) https://docs.svar.dev/svelte/grid/overview/\n- svar-svelte: DataGrid, Filter, Editor, File Manager and Core are MIT; Gantt, Calendar and Kanban have MIT cores with paid PRO editions; no pivot component in the list; no price list on the page (checked 12 Sep 2026) https://svar.dev/svelte/\n- svar-gantt: Offers paid consulting and custom development services; PRO edition pricing is on a separate page (checked 12 Sep 2026) https://svar.dev/svelte/gantt/\n"
6978
6993
  },
6979
6994
  {
6980
6995
  "slug": "compare/svelte-headless-table",
@@ -7093,8 +7108,8 @@ export const apiReference = {
7093
7108
  ]
7094
7109
  };
7095
7110
  export const apiSurface = {
7096
- "gridVersion": "3.0.8",
7097
- "enterpriseVersion": "3.1.4",
7111
+ "gridVersion": "3.0.10",
7112
+ "enterpriseVersion": "3.1.5",
7098
7113
  "grid": {
7099
7114
  "values": [
7100
7115
  "ALL_FILTER_OPERATORS",
@@ -7351,6 +7366,7 @@ export const apiSurface = {
7351
7366
  "createTreeRowModel",
7352
7367
  "createTypeaheadBuffer",
7353
7368
  "createVirtualizer",
7369
+ "createWindowedData",
7354
7370
  "daysForView",
7355
7371
  "debounce",
7356
7372
  "decadeRange",
@@ -7474,6 +7490,7 @@ export const apiSurface = {
7474
7490
  "isSetOperator",
7475
7491
  "isTypeaheadKey",
7476
7492
  "isValuelessOperator",
7493
+ "isWindowedData",
7477
7494
  "isoWeek",
7478
7495
  "joinInTokens",
7479
7496
  "layoutDataLabels",
@@ -7679,6 +7696,7 @@ export const apiSurface = {
7679
7696
  "warnChartSpec",
7680
7697
  "weekdayOrder",
7681
7698
  "wheelWindow",
7699
+ "windowedSourceOf",
7682
7700
  "withTime",
7683
7701
  "withinWorking",
7684
7702
  "wma",
@@ -8228,6 +8246,7 @@ export const apiSurface = {
8228
8246
  "VirtualRange",
8229
8247
  "VirtualizerOptions",
8230
8248
  "VirtualizerState",
8249
+ "WindowedSource",
8231
8250
  "WorkingWindow",
8232
8251
  "ZoneParts"
8233
8252
  ],
@@ -10071,6 +10090,11 @@ export const apiSurface = {
10071
10090
  "optional": true,
10072
10091
  "type": "ReadonlyArray<{ rowIndex: number; colIndex: number; rowSpan: number; colSpan: number }>"
10073
10092
  },
10093
+ {
10094
+ "name": "rowLayout",
10095
+ "optional": true,
10096
+ "type": "\"flex\" | \"table\""
10097
+ },
10074
10098
  {
10075
10099
  "name": "pinnedBottomRows",
10076
10100
  "optional": true,