@svgrid/mcp 2.3.0 → 2.3.2

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
@@ -951,7 +951,7 @@ export const examples = [
951
951
  "path": "examples/src/demos/257-tabs-tree-form.svelte",
952
952
  "title": "Tabs Tree Form",
953
953
  "blurb": "Layout & composite - SvTabs, SvTree and SvForm. The form is schema-driven and wires the rest of the UI kit (inputs, select, switch, date, rating).",
954
- "source": "<script lang=\"ts\">\n /**\n * Layout & composite - SvTabs, SvTree and SvForm. The form is schema-driven\n * and wires the rest of the UI kit (inputs, select, switch, date, rating).\n */\n import { SvTabs, SvTree, SvForm } from '@svgrid/grid'\n import type { SvTreeNode, FormField } from '@svgrid/grid'\n\n let tab = $state('form')\n\n const tree: SvTreeNode[] = [\n { id: 'src', label: 'src', children: [\n { id: 'comp', label: 'components', children: [\n { id: 'a', label: 'SvCalendar.svelte' },\n { id: 'b', label: 'SvTree.svelte' },\n { id: 'c', label: 'SvForm.svelte' },\n ] },\n { id: 'lib', label: 'lib', children: [{ id: 'd', label: 'popover.ts' }, { id: 'e', label: 'date-core.ts' }] },\n { id: 'idx', label: 'index.ts' },\n ] },\n { id: 'pkg', label: 'package.json' },\n ]\n let treeSel = $state<string | null>('a')\n let checked = $state<string[]>([])\n\n const fields: FormField[] = [\n { name: 'name', label: 'Full name', required: true },\n { name: 'email', label: 'Email', type: 'email', required: true, validate: (v) => (v && !String(v).includes('@') ? 'Enter a valid email' : null) },\n { name: 'plan', label: 'Plan', type: 'select', options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }, { value: 'ent', label: 'Enterprise' }] },\n { name: 'seats', label: 'Seats', type: 'number' },\n { name: 'starts', label: 'Start date', type: 'date' },\n { name: 'newsletter', label: 'Subscribe to the newsletter', type: 'switch', full: true },\n { name: 'notes', label: 'Notes', type: 'textarea', full: true },\n ]\n let submitted = $state<Record<string, any> | null>(null)\n</script>\n\n<div class=\"wrap\">\n <header>\n <h2>Layout &amp; composite</h2>\n <p>SvTabs, SvTree and a schema-driven SvForm - the last wires the rest of the kit. All theme from <code>--sg-*</code>.</p>\n </header>\n\n <SvTabs\n tabs={[{ id: 'form', label: 'Form' }, { id: 'tree', label: 'Tree' }, { id: 'pill', label: 'Pill tabs' }]}\n value={tab}\n onChange={(id) => (tab = id)}\n >\n {#snippet panel(active)}\n {#if active === 'form'}\n <div class=\"pane\">\n <SvForm {fields} columns={2} initial={{ plan: 'pro', seats: 5 }} onSubmit={(v) => (submitted = v)} submitLabel=\"Create account\" />\n {#if submitted}<pre class=\"result\">{JSON.stringify(submitted, null, 2)}</pre>{/if}\n </div>\n {:else if active === 'tree'}\n <div class=\"pane trees\">\n <div>\n <h4>Single select</h4>\n <SvTree nodes={tree} selected={treeSel} expandedIds={['src', 'comp']} onSelect={(id) => (treeSel = id)} />\n <p class=\"muted\">Selected: <strong>{treeSel}</strong></p>\n </div>\n <div>\n <h4>Cascading checkboxes</h4>\n <SvTree nodes={tree} checkable checked={checked} expandedIds={['src', 'comp', 'lib']} onCheck={(ids) => (checked = ids)} />\n <p class=\"muted\">{checked.length} checked</p>\n </div>\n </div>\n {:else}\n <div class=\"pane\">\n <SvTabs variant=\"pill\" tabs={[{ id: '1', label: 'Overview' }, { id: '2', label: 'Activity' }, { id: '3', label: 'Settings' }]}>\n {#snippet panel(a)}\n <p class=\"muted\">Pill-style tab panel: <strong>{a}</strong>. Arrow keys navigate; Home/End jump.</p>\n {/snippet}\n </SvTabs>\n </div>\n {/if}\n {/snippet}\n </SvTabs>\n</div>\n\n<style>\n .wrap { padding: 22px; max-width: 820px; display: flex; flex-direction: column; gap: 12px; }\n header h2 { margin: 0 0 4px; font-size: 20px; font-weight: 700; }\n header p { margin: 0; color: var(--sg-muted, #64748b); font-size: 13.5px; }\n code { background: var(--sg-row-hover-bg, #eef2ff); padding: 1px 5px; border-radius: 5px; font-size: 12px; }\n .pane { padding-top: 6px; }\n .trees { display: flex; gap: 40px; flex-wrap: wrap; }\n .trees h4 { margin: 0 0 8px; font-size: 13px; }\n .muted { color: var(--sg-muted, #64748b); font-size: 12.5px; }\n .result { margin-top: 14px; background: var(--sg-header-bg, #f8fafc); border: 1px solid var(--sg-border, #e2e8f0); border-radius: 8px; padding: 10px 12px; font-size: 12px; overflow: auto; }\n</style>\n"
954
+ "source": "<script lang=\"ts\">\n /**\n * Layout & composite - SvTabs, SvTree and SvForm. The form is schema-driven\n * and wires the rest of the UI kit (inputs, select, switch, date, rating).\n */\n import { SvTabs, SvTree, SvForm } from '@svgrid/grid'\n import type { SvTreeNode, FormField } from '@svgrid/grid'\n\n let tab = $state('form')\n\n const tree: SvTreeNode[] = [\n { id: 'src', label: 'src', children: [\n { id: 'comp', label: 'components', children: [\n { id: 'a', label: 'SvCalendar.svelte' },\n { id: 'b', label: 'SvTree.svelte' },\n { id: 'c', label: 'SvForm.svelte' },\n ] },\n { id: 'lib', label: 'lib', children: [{ id: 'd', label: 'popover.ts' }, { id: 'e', label: 'date-core.ts' }] },\n { id: 'idx', label: 'index.ts' },\n ] },\n { id: 'pkg', label: 'package.json' },\n ]\n let treeSel = $state<string | null>('a')\n let checked = $state<string[]>([])\n\n const fields: FormField[] = [\n { name: 'name', label: 'Full name', required: true },\n { name: 'email', label: 'Email', type: 'email', required: true, validate: (v) => (v && !String(v).includes('@') ? 'Enter a valid email' : null) },\n { name: 'plan', label: 'Plan', type: 'select', options: [{ value: 'free', label: 'Free' }, { value: 'pro', label: 'Pro' }, { value: 'ent', label: 'Enterprise' }] },\n { name: 'seats', label: 'Seats', type: 'number' },\n { name: 'starts', label: 'Start date', type: 'date' },\n { name: 'newsletter', label: 'Subscribe to the newsletter', type: 'switch', full: true },\n { name: 'notes', label: 'Notes', type: 'textarea', full: true },\n ]\n let submitted = $state<Record<string, any> | null>(null)\n</script>\n\n<div class=\"wrap\">\n <header>\n <h2>Layout &amp; composite</h2>\n <p>SvTabs, SvTree and a schema-driven SvForm - the last wires the rest of the kit. All theme from <code>--sg-*</code>.</p>\n </header>\n\n <SvTabs\n tabs={[{ id: 'form', label: 'Form' }, { id: 'tree', label: 'Tree' }, { id: 'pill', label: 'Pill tabs' }]}\n value={tab}\n onChange={(id) => (tab = id)}\n >\n {#snippet panel(active)}\n {#if active === 'form'}\n <div class=\"pane\">\n <SvForm {fields} columns={2} initial={{ plan: 'pro', seats: 5 }} onSubmit={(v) => { submitted = v }} submitLabel=\"Create account\" />\n {#if submitted}<pre class=\"result\">{JSON.stringify(submitted, null, 2)}</pre>{/if}\n </div>\n {:else if active === 'tree'}\n <div class=\"pane trees\">\n <div>\n <h4>Single select</h4>\n <SvTree nodes={tree} selected={treeSel} expandedIds={['src', 'comp']} onSelect={(id) => (treeSel = id)} />\n <p class=\"muted\">Selected: <strong>{treeSel}</strong></p>\n </div>\n <div>\n <h4>Cascading checkboxes</h4>\n <SvTree nodes={tree} checkable checked={checked} expandedIds={['src', 'comp', 'lib']} onCheck={(ids) => (checked = ids)} />\n <p class=\"muted\">{checked.length} checked</p>\n </div>\n </div>\n {:else}\n <div class=\"pane\">\n <SvTabs variant=\"pill\" tabs={[{ id: '1', label: 'Overview' }, { id: '2', label: 'Activity' }, { id: '3', label: 'Settings' }]}>\n {#snippet panel(a)}\n <p class=\"muted\">Pill-style tab panel: <strong>{a}</strong>. Arrow keys navigate; Home/End jump.</p>\n {/snippet}\n </SvTabs>\n </div>\n {/if}\n {/snippet}\n </SvTabs>\n</div>\n\n<style>\n .wrap { padding: 22px; max-width: 820px; display: flex; flex-direction: column; gap: 12px; }\n header h2 { margin: 0 0 4px; font-size: 20px; font-weight: 700; }\n header p { margin: 0; color: var(--sg-muted, #64748b); font-size: 13.5px; }\n code { background: var(--sg-row-hover-bg, #eef2ff); padding: 1px 5px; border-radius: 5px; font-size: 12px; }\n .pane { padding-top: 6px; }\n .trees { display: flex; gap: 40px; flex-wrap: wrap; }\n .trees h4 { margin: 0 0 8px; font-size: 13px; }\n .muted { color: var(--sg-muted, #64748b); font-size: 12.5px; }\n .result { margin-top: 14px; background: var(--sg-header-bg, #f8fafc); border: 1px solid var(--sg-border, #e2e8f0); border-radius: 8px; padding: 10px 12px; font-size: 12px; overflow: auto; }\n</style>\n"
955
955
  },
956
956
  {
957
957
  "id": "258-calendar-range",
@@ -1553,7 +1553,7 @@ export const examples = [
1553
1553
  "path": "examples/src/demos/337-account-settings-console.svelte",
1554
1554
  "title": "Account Settings Console",
1555
1555
  "blurb": "Account & Security settings console - a real SaaS settings surface composed entirely from the SvGrid UI kit. It foregrounds the newest/upgraded pieces: SvMenubar app bar, promise + action (Undo) toasts on save, SvPopconfirm for",
1556
- "source": "<script lang=\"ts\">\n /**\n * Account & Security settings console - a real SaaS settings surface composed\n * entirely from the SvGrid UI kit. It foregrounds the newest/upgraded pieces:\n * SvMenubar app bar, promise + action (Undo) toasts on save, SvPopconfirm for\n * destructive rows, SvHoverCard teammate previews, and frame input adornments\n * (leading icons, prefix/suffix affixes, masked secrets).\n */\n import {\n SvMenubar, SvTabs, SvCard, SvStat, SvTextInput, SvSwitchButton,\n SvButton, SvBadge, SvAvatar, SvHoverCard, SvPopconfirm, SvDivider, SvToaster,\n toast, type MenubarMenu, type MenuItem,\n } from '@svgrid/grid'\n\n // --- profile ---\n let fullName = $state('Ada Lovelace')\n let email = $state('ada@northwind.io')\n let company = $state('Northwind Analytics')\n let site = $state('northwind.io')\n\n // --- security ---\n let twoFA = $state(true)\n let sso = $state(false)\n let sessionTimeout = $state(30)\n let apiKey = $state('sk_live_4f8a92c1d7e6')\n\n // --- team ---\n type Member = { id: number; name: string; email: string; role: string; tasks: number; status: 'active' | 'invited' }\n let members = $state<Member[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@northwind.io', role: 'Owner', tasks: 12, status: 'active' },\n { id: 2, name: 'Grace Hopper', email: 'grace@northwind.io', role: 'Admin', tasks: 8, status: 'active' },\n { id: 3, name: 'Alan Turing', email: 'alan@northwind.io', role: 'Editor', tasks: 5, status: 'active' },\n { id: 4, name: 'Katherine Johnson', email: 'kat@northwind.io', role: 'Viewer', tasks: 0, status: 'invited' },\n ])\n\n const menus: MenubarMenu[] = [\n { label: 'File', items: [\n { label: 'Export settings', onSelect: () => toast('Exported settings.json') },\n { label: 'Import...', onSelect: () => toast('Import cancelled') },\n ] },\n { label: 'Team', items: [\n { label: 'Invite member', shortcut: 'Ctrl+I', onSelect: inviteMember },\n { label: 'Manage roles', onSelect: () => toast('Opening roles') },\n ] },\n { label: 'Security', items: [\n { label: 'Rotate API key', onSelect: rotateKey },\n { label: 'View audit log', onSelect: () => toast('Opening audit log') },\n ] },\n { label: 'Help', items: [{ label: 'Docs', onSelect: () => toast('Opening docs') }, { label: 'Contact support', onSelect: () => toast('Support ticket started') }] },\n ]\n const onMenu = (_: MenuItem) => {}\n\n const tabs = [\n { id: 'profile', label: 'Profile' },\n { id: 'team', label: 'Team' },\n { id: 'security', label: 'Security' },\n ]\n let tab = $state('profile')\n\n // --- actions with promise + action toasts ---\n function fakeSave(ms = 900, fail = false) {\n return new Promise<void>((res, rej) => setTimeout(() => (fail ? rej(new Error('network')) : res()), ms))\n }\n function saveProfile() {\n toast.promise(fakeSave(), {\n loading: 'Saving profile...',\n success: 'Profile saved',\n error: (e) => `Could not save: ${(e as Error).message}`,\n })\n }\n function rotateKey() {\n const prev = apiKey\n apiKey = 'sk_live_' + Math.round(performance.now()).toString(16).slice(0, 12)\n toast('API key rotated', {\n variant: 'warning',\n action: { label: 'Undo', onClick: () => { apiKey = prev; toast.success('Key restored') } },\n })\n }\n function inviteMember() {\n const id = Math.max(0, ...members.map((m) => m.id)) + 1\n members = [...members, { id, name: 'New Teammate', email: 'pending@northwind.io', role: 'Viewer', tasks: 0, status: 'invited' }]\n toast.success('Invite sent')\n }\n function removeMember(m: Member) {\n members = members.filter((x) => x.id !== m.id)\n toast('Removed ' + m.name, {\n action: { label: 'Undo', onClick: () => { members = [...members, m].sort((a, b) => a.id - b.id) } },\n })\n }\n\n const activeCount = $derived(members.filter((m) => m.status === 'active').length)\n</script>\n\n<div class=\"wrap\">\n <SvMenubar {menus} onSelect={onMenu} ariaLabel=\"Settings menu\" />\n\n <div class=\"stats\">\n <SvCard><SvStat label=\"Seats used\" value={`${activeCount} / 10`} hint=\"2 invites pending\" /></SvCard>\n <SvCard><SvStat label=\"API calls (24h)\" value=\"18.2k\" delta=\"+6%\" trend=\"up\" /></SvCard>\n <SvCard><SvStat label=\"Storage\" value=\"42 GB\" hint=\"of 100 GB\" /></SvCard>\n <SvCard><SvStat label=\"Plan\" value=\"Business\" hint=\"Renews Aug 1\" /></SvCard>\n </div>\n\n <SvTabs {tabs} bind:value={tab} />\n\n {#if tab === 'profile'}\n <SvCard title=\"Profile\" subtitle=\"How your account appears to teammates.\">\n <div class=\"grid\">\n <SvTextInput label=\"Full name\" bind:value={fullName} block>\n {#snippet leading()}<span class=\"ic\">šŸ‘¤</span>{/snippet}\n </SvTextInput>\n <SvTextInput label=\"Email\" type=\"email\" bind:value={email} block>\n {#snippet leading()}<span class=\"ic\">āœ‰</span>{/snippet}\n </SvTextInput>\n <SvTextInput label=\"Company\" bind:value={company} block />\n <SvTextInput label=\"Website\" bind:value={site} prefix=\"https://\" block />\n </div>\n {#snippet footer()}\n <div class=\"actions\">\n <SvButton variant=\"ghost\">Cancel</SvButton>\n <SvButton variant=\"primary\" onclick={saveProfile}>Save changes</SvButton>\n </div>\n {/snippet}\n </SvCard>\n {:else if tab === 'team'}\n <SvCard title=\"Team\" subtitle=\"Invite teammates and manage their roles.\">\n <div class=\"card-head-action\">\n <SvButton size=\"sm\" variant=\"outline\" onclick={inviteMember}>Invite member</SvButton>\n </div>\n <ul class=\"members\">\n {#each members as m (m.id)}\n <li>\n <SvHoverCard placement=\"right\">\n {#snippet anchor()}<button class=\"who\"><SvAvatar name={m.name} size=\"sm\" />{m.name}</button>{/snippet}\n <div class=\"prof\">\n <SvAvatar name={m.name} />\n <div>\n <strong>{m.name}</strong>\n <div class=\"muted\">{m.email}</div>\n <div class=\"muted\">{m.tasks} open tasks - {m.role}</div>\n </div>\n </div>\n </SvHoverCard>\n <span class=\"role\"><SvBadge variant={m.role === 'Owner' ? 'accent' : 'neutral'}>{m.role}</SvBadge></span>\n <span class=\"st\">{#if m.status === 'invited'}<SvBadge variant=\"warning\" dot>Invited</SvBadge>{:else}<SvBadge variant=\"success\" dot>Active</SvBadge>{/if}</span>\n <span class=\"rm\">\n {#if m.role !== 'Owner'}\n <SvPopconfirm title={`Remove ${m.name}?`} description=\"They lose access immediately.\" confirmLabel=\"Remove\" confirmVariant=\"danger\" onConfirm={() => removeMember(m)}>\n {#snippet anchor()}<SvButton size=\"sm\" variant=\"ghost\">Remove</SvButton>{/snippet}\n </SvPopconfirm>\n {/if}\n </span>\n </li>\n {/each}\n </ul>\n </SvCard>\n {:else}\n <SvCard title=\"Security\" subtitle=\"Protect your account and data.\">\n <div class=\"rows\">\n <div class=\"setting\"><div><strong>Two-factor authentication</strong><div class=\"muted\">Require a code at sign-in.</div></div><SvSwitchButton bind:checked={twoFA} /></div>\n <SvDivider />\n <div class=\"setting\"><div><strong>Single sign-on (SSO)</strong><div class=\"muted\">Let members sign in with your IdP.</div></div><SvSwitchButton bind:checked={sso} /></div>\n <SvDivider />\n <div class=\"setting\">\n <div><strong>API key</strong><div class=\"muted\">Used for server-to-server calls.</div></div>\n <div class=\"keyrow\">\n <SvTextInput value={apiKey} readonly width={220}>\n {#snippet leading()}<span class=\"ic\">šŸ”‘</span>{/snippet}\n </SvTextInput>\n <SvPopconfirm title=\"Rotate API key?\" description=\"The current key stops working right away.\" confirmLabel=\"Rotate\" confirmVariant=\"danger\" onConfirm={rotateKey}>\n {#snippet anchor()}<SvButton size=\"sm\" variant=\"outline\">Rotate</SvButton>{/snippet}\n </SvPopconfirm>\n </div>\n </div>\n </div>\n </SvCard>\n {/if}\n</div>\n\n<SvToaster position=\"bottom-right\" />\n\n<style>\n .wrap { padding: 20px; display: flex; flex-direction: column; gap: 16px; max-width: 780px; }\n .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }\n .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }\n .actions { display: flex; justify-content: flex-end; gap: 8px; }\n .card-head-action { display: flex; justify-content: flex-end; }\n .members { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }\n .members li { display: grid; grid-template-columns: 1fr auto auto auto; align-items: center; gap: 12px; padding: 9px 2px; border-bottom: 1px solid var(--sg-border, #eef2f7); }\n .who { display: inline-flex; align-items: center; gap: 9px; background: none; border: 0; font: inherit; font-size: 13.5px; color: inherit; cursor: pointer; padding: 0; }\n .prof { display: flex; gap: 12px; align-items: center; }\n .prof strong { font-size: 14px; }\n .muted { color: var(--sg-muted, #64748b); font-size: 12.5px; }\n .rows { display: flex; flex-direction: column; }\n .setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 6px 0; }\n .setting strong { font-size: 13.5px; }\n .keyrow { display: flex; align-items: center; gap: 8px; }\n .ic { display: inline-flex; font-size: 13px; }\n</style>\n"
1556
+ "source": "<script lang=\"ts\">\n /**\n * Account & Security settings console - a real SaaS settings surface composed\n * entirely from the SvGrid UI kit. It foregrounds the newest/upgraded pieces:\n * SvMenubar app bar, promise + action (Undo) toasts on save, SvPopconfirm for\n * destructive rows, SvHoverCard teammate previews, and frame input adornments\n * (leading icons, prefix/suffix affixes, masked secrets).\n */\n import {\n SvMenubar, SvTabs, SvCard, SvStat, SvTextInput, SvSwitchButton,\n SvButton, SvBadge, SvAvatar, SvHoverCard, SvPopconfirm, SvDivider, SvToaster,\n toast, type MenubarMenu, type MenuItem,\n } from '@svgrid/grid'\n\n // --- profile ---\n let fullName = $state('Ada Lovelace')\n let email = $state('ada@northwind.io')\n let company = $state('Northwind Analytics')\n let site = $state('northwind.io')\n\n // --- security ---\n let twoFA = $state(true)\n let sso = $state(false)\n let sessionTimeout = $state(30)\n let apiKey = $state('sk_live_4f8a92c1d7e6')\n\n // --- team ---\n type Member = { id: number; name: string; email: string; role: string; tasks: number; status: 'active' | 'invited' }\n let members = $state<Member[]>([\n { id: 1, name: 'Ada Lovelace', email: 'ada@northwind.io', role: 'Owner', tasks: 12, status: 'active' },\n { id: 2, name: 'Grace Hopper', email: 'grace@northwind.io', role: 'Admin', tasks: 8, status: 'active' },\n { id: 3, name: 'Alan Turing', email: 'alan@northwind.io', role: 'Editor', tasks: 5, status: 'active' },\n { id: 4, name: 'Katherine Johnson', email: 'kat@northwind.io', role: 'Viewer', tasks: 0, status: 'invited' },\n ])\n\n const menus: MenubarMenu[] = [\n { label: 'File', items: [\n { label: 'Export settings', onSelect: () => toast('Exported settings.json') },\n { label: 'Import...', onSelect: () => toast('Import cancelled') },\n ] },\n { label: 'Team', items: [\n { label: 'Invite member', shortcut: 'Ctrl+I', onSelect: inviteMember },\n { label: 'Manage roles', onSelect: () => toast('Opening roles') },\n ] },\n { label: 'Security', items: [\n { label: 'Rotate API key', onSelect: rotateKey },\n { label: 'View audit log', onSelect: () => toast('Opening audit log') },\n ] },\n { label: 'Help', items: [{ label: 'Docs', onSelect: () => toast('Opening docs') }, { label: 'Contact support', onSelect: () => toast('Support ticket started') }] },\n ]\n const onMenu = (_: MenuItem) => {}\n\n const tabs = [\n { id: 'profile', label: 'Profile' },\n { id: 'team', label: 'Team' },\n { id: 'security', label: 'Security' },\n ]\n let tab = $state('profile')\n\n // --- actions with promise + action toasts ---\n function fakeSave(ms = 900, fail = false) {\n return new Promise<void>((res, rej) => setTimeout(() => (fail ? rej(new Error('network')) : res()), ms))\n }\n function saveProfile() {\n toast.promise(fakeSave(), {\n loading: 'Saving profile...',\n success: 'Profile saved',\n error: (e) => `Could not save: ${(e as Error).message}`,\n })\n }\n function rotateKey() {\n const prev = apiKey\n apiKey = 'sk_live_' + Math.round(performance.now()).toString(16).slice(0, 12)\n toast('API key rotated', {\n variant: 'warning',\n action: { label: 'Undo', onClick: () => { apiKey = prev; toast.success('Key restored') } },\n })\n }\n function inviteMember() {\n const id = Math.max(0, ...members.map((m) => m.id)) + 1\n members = [...members, { id, name: 'New Teammate', email: 'pending@northwind.io', role: 'Viewer', tasks: 0, status: 'invited' }]\n toast.success('Invite sent')\n }\n function removeMember(m: Member) {\n members = members.filter((x) => x.id !== m.id)\n toast('Removed ' + m.name, {\n action: { label: 'Undo', onClick: () => { members = [...members, m].sort((a, b) => a.id - b.id) } },\n })\n }\n\n const activeCount = $derived(members.filter((m) => m.status === 'active').length)\n</script>\n\n<div class=\"wrap\">\n <SvMenubar {menus} onSelect={onMenu} ariaLabel=\"Settings menu\" />\n\n <div class=\"stats\">\n <SvCard><SvStat label=\"Seats used\" value={`${activeCount} / 10`} hint=\"2 invites pending\" /></SvCard>\n <SvCard><SvStat label=\"API calls (24h)\" value=\"18.2k\" delta=\"+6%\" trend=\"up\" /></SvCard>\n <SvCard><SvStat label=\"Storage\" value=\"42 GB\" hint=\"of 100 GB\" /></SvCard>\n <SvCard><SvStat label=\"Plan\" value=\"Business\" hint=\"Renews Aug 1\" /></SvCard>\n </div>\n\n <SvTabs {tabs} bind:value={tab} />\n\n {#if tab === 'profile'}\n <SvCard title=\"Profile\" subtitle=\"How your account appears to teammates.\">\n <div class=\"grid\">\n <SvTextInput label=\"Full name\" bind:value={fullName} block>\n {#snippet leading()}<span class=\"ic\">šŸ‘¤</span>{/snippet}\n </SvTextInput>\n <SvTextInput label=\"Email\" type=\"email\" bind:value={email} block>\n {#snippet leading()}<span class=\"ic\">āœ‰</span>{/snippet}\n </SvTextInput>\n <SvTextInput label=\"Company\" bind:value={company} block />\n <SvTextInput label=\"Website\" bind:value={site} prefix=\"https://\" block />\n </div>\n {#snippet footer()}\n <div class=\"actions\">\n <SvButton variant=\"ghost\">Cancel</SvButton>\n <SvButton variant=\"primary\" onclick={saveProfile}>Save changes</SvButton>\n </div>\n {/snippet}\n </SvCard>\n {:else if tab === 'team'}\n <SvCard title=\"Team\" subtitle=\"Invite teammates and manage their roles.\">\n <div class=\"card-head-action\">\n <SvButton size=\"sm\" variant=\"outline\" onclick={inviteMember}>Invite member</SvButton>\n </div>\n <ul class=\"members\">\n {#each members as m (m.id)}\n <li>\n <SvHoverCard placement=\"right\">\n {#snippet anchor()}<button class=\"who\"><SvAvatar name={m.name} size=\"sm\" />{m.name}</button>{/snippet}\n <div class=\"prof\">\n <SvAvatar name={m.name} />\n <div>\n <strong>{m.name}</strong>\n <div class=\"muted\">{m.email}</div>\n <div class=\"muted\">{m.tasks} open tasks - {m.role}</div>\n </div>\n </div>\n </SvHoverCard>\n <span class=\"role\"><SvBadge variant={m.role === 'Owner' ? 'accent' : 'neutral'}>{m.role}</SvBadge></span>\n <span class=\"st\">{#if m.status === 'invited'}<SvBadge variant=\"warning\" dot>Invited</SvBadge>{:else}<SvBadge variant=\"success\" dot>Active</SvBadge>{/if}</span>\n <span class=\"rm\">\n {#if m.role !== 'Owner'}\n <SvPopconfirm title={`Remove ${m.name}?`} description=\"They lose access immediately.\" confirmLabel=\"Remove\" confirmVariant=\"danger\" onConfirm={() => removeMember(m)}>\n {#snippet anchor()}<SvButton size=\"sm\" variant=\"ghost\">Remove</SvButton>{/snippet}\n </SvPopconfirm>\n {/if}\n </span>\n </li>\n {/each}\n </ul>\n </SvCard>\n {:else}\n <SvCard title=\"Security\" subtitle=\"Protect your account and data.\">\n <div class=\"rows\">\n <div class=\"setting\"><div><strong>Two-factor authentication</strong><div class=\"muted\">Require a code at sign-in.</div></div><SvSwitchButton checked={twoFA} onChange={(v) => (twoFA = v)} /></div>\n <SvDivider />\n <div class=\"setting\"><div><strong>Single sign-on (SSO)</strong><div class=\"muted\">Let members sign in with your IdP.</div></div><SvSwitchButton checked={sso} onChange={(v) => (sso = v)} /></div>\n <SvDivider />\n <div class=\"setting\">\n <div><strong>API key</strong><div class=\"muted\">Used for server-to-server calls.</div></div>\n <div class=\"keyrow\">\n <SvTextInput value={apiKey} readonly>\n {#snippet leading()}<span class=\"ic\">šŸ”‘</span>{/snippet}\n </SvTextInput>\n <SvPopconfirm title=\"Rotate API key?\" description=\"The current key stops working right away.\" confirmLabel=\"Rotate\" confirmVariant=\"danger\" onConfirm={rotateKey}>\n {#snippet anchor()}<SvButton size=\"sm\" variant=\"outline\">Rotate</SvButton>{/snippet}\n </SvPopconfirm>\n </div>\n </div>\n </div>\n </SvCard>\n {/if}\n</div>\n\n<SvToaster position=\"bottom-right\" />\n\n<style>\n .wrap { padding: 20px; display: flex; flex-direction: column; gap: 16px; max-width: 780px; }\n .stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; }\n .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }\n .actions { display: flex; justify-content: flex-end; gap: 8px; }\n .card-head-action { display: flex; justify-content: flex-end; }\n .members { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }\n .members li { display: grid; grid-template-columns: 1fr auto auto auto; align-items: center; gap: 12px; padding: 9px 2px; border-bottom: 1px solid var(--sg-border, #eef2f7); }\n .who { display: inline-flex; align-items: center; gap: 9px; background: none; border: 0; font: inherit; font-size: 13.5px; color: inherit; cursor: pointer; padding: 0; }\n .prof { display: flex; gap: 12px; align-items: center; }\n .prof strong { font-size: 14px; }\n .muted { color: var(--sg-muted, #64748b); font-size: 12.5px; }\n .rows { display: flex; flex-direction: column; }\n .setting { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 6px 0; }\n .setting strong { font-size: 13.5px; }\n .keyrow { display: flex; align-items: center; gap: 8px; }\n .ic { display: inline-flex; font-size: 13px; }\n</style>\n"
1557
1557
  },
1558
1558
  {
1559
1559
  "id": "337-live-rest-dummyjson",
@@ -1616,7 +1616,7 @@ export const examples = [
1616
1616
  "path": "examples/src/demos/343-invoice-builder.svelte",
1617
1617
  "title": "Invoice Builder",
1618
1618
  "blurb": "Invoice builder - an adornment-heavy money form composed from the UI kit. Foregrounds frame input adornments (currency prefix, % suffix, masked tax id), SvPopconfirm on line-item delete, live SvStat totals, SvHoverCard on the tax",
1619
- "source": "<script lang=\"ts\">\n /**\n * Invoice builder - an adornment-heavy money form composed from the UI kit.\n * Foregrounds frame input adornments (currency prefix, % suffix, masked tax id),\n * SvPopconfirm on line-item delete, live SvStat totals, SvHoverCard on the tax\n * hint, and a promise toast when the invoice is sent.\n */\n import {\n SvTextInput, SvNumberInput, SvMaskedInput, SvButton, SvCard, SvStat, SvBadge,\n SvHoverCard, SvPopconfirm, SvSwitchButton, SvDivider, SvToaster, toast,\n } from '@svgrid/grid'\n\n let client = $state('Globex Corporation')\n let taxId = $state('12-3456789')\n let taxRate = $state(8.5)\n let discount = $state(0)\n let sendCopy = $state(true)\n\n type Line = { id: number; desc: string; qty: number; price: number }\n let lines = $state<Line[]>([\n { id: 1, desc: 'Design retainer', qty: 1, price: 4800 },\n { id: 2, desc: 'Engineering (hours)', qty: 40, price: 145 },\n { id: 3, desc: 'Cloud hosting', qty: 3, price: 220 },\n ])\n let seq = 3\n\n const lineTotal = (l: Line) => l.qty * l.price\n const subtotal = $derived(lines.reduce((s, l) => s + lineTotal(l), 0))\n const discounted = $derived(subtotal * (1 - (discount || 0) / 100))\n const tax = $derived(discounted * (taxRate || 0) / 100)\n const total = $derived(discounted + tax)\n const money = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' })\n\n function addLine() { lines = [...lines, { id: ++seq, desc: 'New item', qty: 1, price: 0 }] }\n function removeLine(l: Line) {\n lines = lines.filter((x) => x.id !== l.id)\n toast('Removed line', { action: { label: 'Undo', onClick: () => { lines = [...lines, l].sort((a, b) => a.id - b.id) } } })\n }\n function send() {\n toast.promise(\n new Promise((res) => setTimeout(res, 1000)),\n { loading: `Sending invoice to ${client}...`, success: `Invoice sent (${money(total)})`, error: 'Send failed' },\n )\n }\n</script>\n\n<div class=\"wrap\">\n <header class=\"head\">\n <div>\n <h2>New invoice <SvBadge variant=\"warning\">Draft</SvBadge></h2>\n <p class=\"muted\">Invoice #INV-2043 - due in 30 days</p>\n </div>\n <div class=\"head-actions\">\n <SvButton variant=\"ghost\">Preview</SvButton>\n <SvButton variant=\"primary\" onclick={send}>Send invoice</SvButton>\n </div>\n </header>\n\n <div class=\"cols\">\n <div class=\"main\">\n <SvCard title=\"Bill to\">\n <div class=\"grid2\">\n <SvTextInput label=\"Client\" bind:value={client} block>\n {#snippet leading()}<span class=\"ic\">šŸ¢</span>{/snippet}\n </SvTextInput>\n <SvMaskedInput label=\"Tax ID\" bind:value={taxId} mask=\"##-#######\" block>\n {#snippet leading()}<span class=\"ic\">#</span>{/snippet}\n </SvMaskedInput>\n </div>\n </SvCard>\n\n <SvCard title=\"Line items\">\n <div class=\"lines\">\n <div class=\"lrow lhead\">\n <span>Description</span><span class=\"num\">Qty</span><span class=\"num\">Unit price</span><span class=\"num\">Amount</span><span></span>\n </div>\n {#each lines as l (l.id)}\n <div class=\"lrow\">\n <SvTextInput bind:value={l.desc} block />\n <SvNumberInput bind:value={l.qty} min={0} width={78} />\n <SvNumberInput bind:value={l.price} min={0} prefix=\"$\" grouping width={120} />\n <span class=\"amount\">{money(lineTotal(l))}</span>\n <SvPopconfirm title=\"Delete line?\" confirmLabel=\"Delete\" confirmVariant=\"danger\" onConfirm={() => removeLine(l)}>\n {#snippet anchor()}<SvButton size=\"sm\" variant=\"ghost\" ariaLabel=\"Delete line\">āœ•</SvButton>{/snippet}\n </SvPopconfirm>\n </div>\n {/each}\n </div>\n <SvButton size=\"sm\" variant=\"outline\" onclick={addLine}>+ Add line</SvButton>\n </SvCard>\n </div>\n\n <aside class=\"side\">\n <SvCard title=\"Summary\">\n <div class=\"adjust\">\n <SvNumberInput label=\"Discount\" bind:value={discount} min={0} max={100} suffix=\"%\" width={110} />\n <span class=\"tax-lab\">\n Tax rate\n <SvHoverCard placement=\"top\">\n {#snippet anchor()}<button class=\"info\">?</button>{/snippet}\n <div class=\"muted\" style=\"max-width:200px\">Sales tax applied after any discount. Set 0% for tax-exempt clients.</div>\n </SvHoverCard>\n </span>\n <SvNumberInput bind:value={taxRate} min={0} max={30} step={0.5} precision={1} suffix=\"%\" width={110} />\n </div>\n <SvDivider />\n <dl class=\"totals\">\n <div><dt>Subtotal</dt><dd>{money(subtotal)}</dd></div>\n {#if discount > 0}<div><dt>Discount</dt><dd>-{money(subtotal - discounted)}</dd></div>{/if}\n <div><dt>Tax ({taxRate}%)</dt><dd>{money(tax)}</dd></div>\n </dl>\n <SvDivider />\n <SvStat label=\"Total due\" value={money(total)} hint={`${lines.length} items`} />\n <div class=\"copy\">\n <span>Email me a copy</span><SvSwitchButton bind:checked={sendCopy} />\n </div>\n </SvCard>\n </aside>\n </div>\n</div>\n\n<SvToaster position=\"bottom-right\" />\n\n<style>\n .wrap { padding: 20px; display: flex; flex-direction: column; gap: 16px; max-width: 900px; }\n .head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }\n .head h2 { margin: 0; font-size: 19px; font-weight: 700; display: inline-flex; align-items: center; gap: 8px; }\n .head-actions { display: flex; gap: 8px; }\n .muted { color: var(--sg-muted, #64748b); font-size: 13px; margin: 3px 0 0; }\n .cols { display: grid; grid-template-columns: 1fr 300px; gap: 16px; align-items: start; }\n .main { display: flex; flex-direction: column; gap: 16px; min-width: 0; }\n .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }\n .lines { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }\n .lrow { display: grid; grid-template-columns: 1fr 78px 120px 90px 32px; gap: 10px; align-items: center; }\n .lhead { font-size: 11.5px; font-weight: 600; color: var(--sg-muted, #64748b); text-transform: uppercase; letter-spacing: 0.03em; }\n .lrow .num { text-align: end; }\n .amount { text-align: end; font-variant-numeric: tabular-nums; font-weight: 600; font-size: 13px; }\n .side { position: sticky; top: 12px; }\n .adjust { display: flex; flex-direction: column; gap: 10px; }\n .tax-lab { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--sg-muted, #64748b); }\n .info { width: 17px; height: 17px; border-radius: 50%; border: 0; background: var(--sg-row-hover-bg, #eef2f7); color: var(--sg-muted, #64748b); font-size: 11px; cursor: pointer; }\n .totals { margin: 0; display: flex; flex-direction: column; gap: 6px; }\n .totals div { display: flex; justify-content: space-between; font-size: 13px; }\n .totals dt { color: var(--sg-muted, #64748b); margin: 0; }\n .totals dd { margin: 0; font-variant-numeric: tabular-nums; }\n .copy { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; font-size: 13px; }\n</style>\n"
1619
+ "source": "<script lang=\"ts\">\n /**\n * Invoice builder - an adornment-heavy money form composed from the UI kit.\n * Foregrounds frame input adornments (currency prefix, % suffix, masked tax id),\n * SvPopconfirm on line-item delete, live SvStat totals, SvHoverCard on the tax\n * hint, and a promise toast when the invoice is sent.\n */\n import {\n SvTextInput, SvNumberInput, SvMaskedInput, SvButton, SvCard, SvStat, SvBadge,\n SvHoverCard, SvPopconfirm, SvSwitchButton, SvDivider, SvToaster, toast,\n } from '@svgrid/grid'\n\n let client = $state('Globex Corporation')\n let taxId = $state('12-3456789')\n let taxRate = $state(8.5)\n let discount = $state(0)\n let sendCopy = $state(true)\n\n type Line = { id: number; desc: string; qty: number; price: number }\n let lines = $state<Line[]>([\n { id: 1, desc: 'Design retainer', qty: 1, price: 4800 },\n { id: 2, desc: 'Engineering (hours)', qty: 40, price: 145 },\n { id: 3, desc: 'Cloud hosting', qty: 3, price: 220 },\n ])\n let seq = 3\n\n const lineTotal = (l: Line) => l.qty * l.price\n const subtotal = $derived(lines.reduce((s, l) => s + lineTotal(l), 0))\n const discounted = $derived(subtotal * (1 - (discount || 0) / 100))\n const tax = $derived(discounted * (taxRate || 0) / 100)\n const total = $derived(discounted + tax)\n const money = (n: number) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' })\n\n function addLine() { lines = [...lines, { id: ++seq, desc: 'New item', qty: 1, price: 0 }] }\n function removeLine(l: Line) {\n lines = lines.filter((x) => x.id !== l.id)\n toast('Removed line', { action: { label: 'Undo', onClick: () => { lines = [...lines, l].sort((a, b) => a.id - b.id) } } })\n }\n function send() {\n toast.promise(\n new Promise((res) => setTimeout(res, 1000)),\n { loading: `Sending invoice to ${client}...`, success: `Invoice sent (${money(total)})`, error: 'Send failed' },\n )\n }\n</script>\n\n<div class=\"wrap\">\n <header class=\"head\">\n <div>\n <h2>New invoice <SvBadge variant=\"warning\">Draft</SvBadge></h2>\n <p class=\"muted\">Invoice #INV-2043 - due in 30 days</p>\n </div>\n <div class=\"head-actions\">\n <SvButton variant=\"ghost\">Preview</SvButton>\n <SvButton variant=\"primary\" onclick={send}>Send invoice</SvButton>\n </div>\n </header>\n\n <div class=\"cols\">\n <div class=\"main\">\n <SvCard title=\"Bill to\">\n <div class=\"grid2\">\n <SvTextInput label=\"Client\" bind:value={client} block>\n {#snippet leading()}<span class=\"ic\">šŸ¢</span>{/snippet}\n </SvTextInput>\n <SvMaskedInput label=\"Tax ID\" bind:value={taxId} mask=\"##-#######\" block>\n {#snippet leading()}<span class=\"ic\">#</span>{/snippet}\n </SvMaskedInput>\n </div>\n </SvCard>\n\n <SvCard title=\"Line items\">\n <div class=\"lines\">\n <div class=\"lrow lhead\">\n <span>Description</span><span class=\"num\">Qty</span><span class=\"num\">Unit price</span><span class=\"num\">Amount</span><span></span>\n </div>\n {#each lines as l (l.id)}\n <div class=\"lrow\">\n <SvTextInput bind:value={l.desc} block />\n <SvNumberInput bind:value={l.qty} min={0} width={78} />\n <SvNumberInput bind:value={l.price} min={0} prefix=\"$\" grouping width={120} />\n <span class=\"amount\">{money(lineTotal(l))}</span>\n <SvPopconfirm title=\"Delete line?\" confirmLabel=\"Delete\" confirmVariant=\"danger\" onConfirm={() => removeLine(l)}>\n {#snippet anchor()}<SvButton size=\"sm\" variant=\"ghost\" ariaLabel=\"Delete line\">āœ•</SvButton>{/snippet}\n </SvPopconfirm>\n </div>\n {/each}\n </div>\n <SvButton size=\"sm\" variant=\"outline\" onclick={addLine}>+ Add line</SvButton>\n </SvCard>\n </div>\n\n <aside class=\"side\">\n <SvCard title=\"Summary\">\n <div class=\"adjust\">\n <SvNumberInput label=\"Discount\" bind:value={discount} min={0} max={100} suffix=\"%\" width={110} />\n <span class=\"tax-lab\">\n Tax rate\n <SvHoverCard placement=\"top\">\n {#snippet anchor()}<button class=\"info\">?</button>{/snippet}\n <div class=\"muted\" style=\"max-width:200px\">Sales tax applied after any discount. Set 0% for tax-exempt clients.</div>\n </SvHoverCard>\n </span>\n <SvNumberInput bind:value={taxRate} min={0} max={30} step={0.5} precision={1} suffix=\"%\" width={110} />\n </div>\n <SvDivider />\n <dl class=\"totals\">\n <div><dt>Subtotal</dt><dd>{money(subtotal)}</dd></div>\n {#if discount > 0}<div><dt>Discount</dt><dd>-{money(subtotal - discounted)}</dd></div>{/if}\n <div><dt>Tax ({taxRate}%)</dt><dd>{money(tax)}</dd></div>\n </dl>\n <SvDivider />\n <SvStat label=\"Total due\" value={money(total)} hint={`${lines.length} items`} />\n <div class=\"copy\">\n <span>Email me a copy</span><SvSwitchButton checked={sendCopy} onChange={(v) => (sendCopy = v)} />\n </div>\n </SvCard>\n </aside>\n </div>\n</div>\n\n<SvToaster position=\"bottom-right\" />\n\n<style>\n .wrap { padding: 20px; display: flex; flex-direction: column; gap: 16px; max-width: 900px; }\n .head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap; }\n .head h2 { margin: 0; font-size: 19px; font-weight: 700; display: inline-flex; align-items: center; gap: 8px; }\n .head-actions { display: flex; gap: 8px; }\n .muted { color: var(--sg-muted, #64748b); font-size: 13px; margin: 3px 0 0; }\n .cols { display: grid; grid-template-columns: 1fr 300px; gap: 16px; align-items: start; }\n .main { display: flex; flex-direction: column; gap: 16px; min-width: 0; }\n .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }\n .lines { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }\n .lrow { display: grid; grid-template-columns: 1fr 78px 120px 90px 32px; gap: 10px; align-items: center; }\n .lhead { font-size: 11.5px; font-weight: 600; color: var(--sg-muted, #64748b); text-transform: uppercase; letter-spacing: 0.03em; }\n .lrow .num { text-align: end; }\n .amount { text-align: end; font-variant-numeric: tabular-nums; font-weight: 600; font-size: 13px; }\n .side { position: sticky; top: 12px; }\n .adjust { display: flex; flex-direction: column; gap: 10px; }\n .tax-lab { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--sg-muted, #64748b); }\n .info { width: 17px; height: 17px; border-radius: 50%; border: 0; background: var(--sg-row-hover-bg, #eef2f7); color: var(--sg-muted, #64748b); font-size: 11px; cursor: pointer; }\n .totals { margin: 0; display: flex; flex-direction: column; gap: 6px; }\n .totals div { display: flex; justify-content: space-between; font-size: 13px; }\n .totals dt { color: var(--sg-muted, #64748b); margin: 0; }\n .totals dd { margin: 0; font-variant-numeric: tabular-nums; }\n .copy { display: flex; align-items: center; justify-content: space-between; margin-top: 12px; font-size: 13px; }\n</style>\n"
1620
1620
  },
1621
1621
  {
1622
1622
  "id": "343-kanban-board",
@@ -1637,7 +1637,7 @@ export const examples = [
1637
1637
  "path": "examples/src/demos/344-server-grouping-model.svelte",
1638
1638
  "title": "Server Grouping Model",
1639
1639
  "blurb": "344. Server grouping (first-class) First-class server-side grouping through ONE getRows contract. The request carries groupBy + groupKeys; createServerGroupModel owns the group",
1640
- "source": "<!-- Documented in: docs/help/server/server-grouping.md -->\n<script lang=\"ts\">\n /**\n * 344. Server grouping (first-class)\n * ----------------------------------\n * First-class server-side grouping through ONE getRows contract. The\n * request carries groupBy + groupKeys; createServerGroupModel owns the group\n * tree (lazy expand per level, aggregation, per-node cache, race-safety) and\n * hands back a flat displayRows list to render. Here a 60,000-row in-memory\n * \"server\" behind 200ms latency - the grid only ever holds what you expand.\n */\n import {\n SvGrid,\n createServerGroupModel,\n serverGroupRows,\n serverGroupNav,\n SvGroupCell,\n SvRowGroupPanel,\n renderComponent,\n tableFeatures,\n type ColumnDef,\n type ServerDataSource,\n type ServerGroupState,\n type ServerGroupGridRow,\n } from '@svgrid/grid'\n\n const features = tableFeatures({})\n\n type Sale = { region: string; country: string; rep: string; amount: number }\n const REGIONS: Record<string, string[]> = {\n Americas: ['US', 'BR', 'CA'],\n EMEA: ['DE', 'UK', 'FR'],\n APAC: ['JP', 'AU', 'IN'],\n }\n\n // The \"database\": 63,000 rows that never touch the grid wholesale.\n const DB: Sale[] = (() => {\n const out: Sale[] = []\n let i = 0\n for (const [region, countries] of Object.entries(REGIONS))\n for (const country of countries)\n for (let k = 0; k < 7000; k++)\n out.push({ region, country, rep: `Rep ${i % 50}`, amount: 500 + ((i++ * 7919) % 9500) })\n return out\n })()\n\n // The server: GROUP BY the requested level within groupKeys, or return leaves.\n const source: ServerDataSource<Sale> = {\n async getRows(req) {\n await new Promise((r) => setTimeout(r, 200)) // simulated latency\n const subset = DB.filter((r) =>\n req.groupKeys.every((k, i) => String((r as Record<string, unknown>)[req.groupBy[i]!]) === k),\n )\n const level = req.groupKeys.length\n if (level < req.groupBy.length) {\n const field = req.groupBy[level]!\n const map = new Map<string, Record<string, unknown>>()\n for (const r of subset) {\n const key = String((r as Record<string, unknown>)[field])\n const g = map.get(key) ?? { [field]: (r as Record<string, unknown>)[field], amount: 0, n: 0 }\n g.amount = (g.amount as number) + r.amount\n g.n = (g.n as number) + 1\n map.set(key, g)\n }\n const rows = [...map.values()] as unknown as Sale[]\n return { rows, rowCount: rows.length }\n }\n // Honor the requested block so intra-group paging returns one page at a time.\n return { rows: subset.slice(req.startRow, req.endRow), rowCount: subset.length }\n },\n }\n\n let view = $state<ServerGroupState<Sale>>()\n const ctl = createServerGroupModel<Sale>(source, {\n groupBy: ['region', 'country'],\n aggregations: [{ col: 'amount', fn: 'sum' }],\n pageSize: 20, // small block so intra-group \"Load more\" is visible on the leaves\n groupFooters: true, // subtotal row after each expanded group\n onChange: (s) => (view = s),\n })\n // Columns the row-group panel lets you group / regroup by (drives ctl.setGroupBy).\n const groupCols = [\n { id: 'region', label: 'Region' },\n { id: 'country', label: 'Country' },\n { id: 'rep', label: 'Rep' },\n ]\n ctl.refresh()\n // One handler drives both the SvGroupCell clicks and the grid's keyboard.\n const nav = serverGroupNav(ctl)\n\n // serverGroupRows maps the controller's displayRows to grid rows; the built-in\n // SvGroupCell draws the expander + indent. No hand-written cell recipe.\n type GridRow = ServerGroupGridRow<Sale> & { n?: number }\n const rows = $derived<GridRow[]>(serverGroupRows(view))\n\n const usd = { type: 'number' as const, options: { style: 'currency', currency: 'USD', maximumFractionDigits: 0 } }\n const columns: ColumnDef<typeof features, GridRow>[] = [\n {\n field: 'region',\n header: 'Group',\n width: 300,\n cell: (ctx) => renderComponent(SvGroupCell, { row: ctx.row.original, onToggle: nav.onToggle, leafField: 'rep' }),\n },\n { field: 'n', header: 'Rows', width: 110, align: 'right' },\n { field: 'amount', header: 'Amount', width: 170, align: 'right', format: usd },\n ]\n</script>\n\n<div class=\"wrap\">\n <p class=\"hint\">\n Click a region to drill into its countries, then into the raw rows. Each expand is one\n <code>getRows</code> with a longer <code>groupKeys</code> - 63,000 rows on the \"server\", the grid\n holds only what you expand.\n </p>\n <SvRowGroupPanel columns={groupCols} groupBy={view?.groupBy ?? []} onChange={(g) => ctl.setGroupBy(g)} />\n <div class=\"grid\"><SvGrid responsive={true} data={rows} {columns} {features} serverGroup={nav} /></div>\n</div>\n\n<style>\n .wrap { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; }\n .hint { font-size: 13px; color: var(--sg-muted, #64748b); margin: 0; }\n .grid { flex: 1; min-height: 0; }\n</style>\n"
1640
+ "source": "<!-- Documented in: docs/help/server/server-grouping.md -->\n<script lang=\"ts\">\n /**\n * 344. Server grouping (first-class)\n * ----------------------------------\n * First-class server-side grouping through ONE getRows contract. The\n * request carries groupBy + groupKeys; createServerGroupModel owns the group\n * tree (lazy expand per level, aggregation, per-node cache, race-safety) and\n * hands back a flat displayRows list to render. Here a 60,000-row in-memory\n * \"server\" behind 200ms latency - the grid only ever holds what you expand.\n */\n import {\n SvGrid,\n createServerGroupModel,\n serverGroupRows,\n serverGroupNav,\n SvGroupCell,\n SvRowGroupPanel,\n renderComponent,\n tableFeatures,\n type ColumnDef,\n type ServerDataSource,\n type ServerGroupState,\n type ServerGroupGridRow,\n } from '@svgrid/grid'\n\n const features = tableFeatures({})\n\n type Sale = { region: string; country: string; rep: string; amount: number }\n const REGIONS: Record<string, string[]> = {\n Americas: ['US', 'BR', 'CA'],\n EMEA: ['DE', 'UK', 'FR'],\n APAC: ['JP', 'AU', 'IN'],\n }\n\n // The \"database\": 63,000 rows that never touch the grid wholesale.\n const DB: Sale[] = (() => {\n const out: Sale[] = []\n let i = 0\n for (const [region, countries] of Object.entries(REGIONS))\n for (const country of countries)\n for (let k = 0; k < 7000; k++)\n out.push({ region, country, rep: `Rep ${i % 50}`, amount: 500 + ((i++ * 7919) % 9500) })\n return out\n })()\n\n // The server: GROUP BY the requested level within groupKeys, or return leaves.\n const source: ServerDataSource<Sale> = {\n async getRows(req) {\n await new Promise((r) => setTimeout(r, 200)) // simulated latency\n // `groupBy` / `groupKeys` are optional on ServerRequest (a flat source may\n // omit them); this grouped source is always called with both.\n const subset = DB.filter((r) =>\n req.groupKeys!.every((k, i) => String((r as Record<string, unknown>)[req.groupBy![i]!]) === k),\n )\n const level = req.groupKeys!.length\n if (level < req.groupBy!.length) {\n const field = req.groupBy![level]!\n const map = new Map<string, Record<string, unknown>>()\n for (const r of subset) {\n const key = String((r as Record<string, unknown>)[field])\n const g = map.get(key) ?? { [field]: (r as Record<string, unknown>)[field], amount: 0, n: 0 }\n g.amount = (g.amount as number) + r.amount\n g.n = (g.n as number) + 1\n map.set(key, g)\n }\n const rows = [...map.values()] as unknown as Sale[]\n return { rows, rowCount: rows.length }\n }\n // Honor the requested block so intra-group paging returns one page at a time.\n return { rows: subset.slice(req.startRow, req.endRow), rowCount: subset.length }\n },\n }\n\n let view = $state<ServerGroupState<Sale>>()\n const ctl = createServerGroupModel<Sale>(source, {\n groupBy: ['region', 'country'],\n aggregations: [{ col: 'amount', fn: 'sum' }],\n pageSize: 20, // small block so intra-group \"Load more\" is visible on the leaves\n groupFooters: true, // subtotal row after each expanded group\n onChange: (s) => (view = s),\n })\n // Columns the row-group panel lets you group / regroup by (drives ctl.setGroupBy).\n const groupCols = [\n { id: 'region', label: 'Region' },\n { id: 'country', label: 'Country' },\n { id: 'rep', label: 'Rep' },\n ]\n ctl.refresh()\n // One handler drives both the SvGroupCell clicks and the grid's keyboard.\n const nav = serverGroupNav(ctl)\n\n // serverGroupRows maps the controller's displayRows to grid rows; the built-in\n // SvGroupCell draws the expander + indent. No hand-written cell recipe.\n type GridRow = ServerGroupGridRow<Sale> & { n?: number }\n const rows = $derived<GridRow[]>(serverGroupRows(view))\n\n const usd = { type: 'number' as const, options: { style: 'currency' as const, currency: 'USD', maximumFractionDigits: 0 } }\n const columns: ColumnDef<typeof features, GridRow>[] = [\n {\n field: 'region',\n header: 'Group',\n width: 300,\n cell: (ctx) => renderComponent(SvGroupCell, { row: ctx.row.original, onToggle: () => nav.onToggle(ctx.row.original), leafField: 'rep' }),\n },\n { field: 'n', header: 'Rows', width: 110, align: 'right' },\n { field: 'amount', header: 'Amount', width: 170, align: 'right', format: usd },\n ]\n</script>\n\n<div class=\"wrap\">\n <p class=\"hint\">\n Click a region to drill into its countries, then into the raw rows. Each expand is one\n <code>getRows</code> with a longer <code>groupKeys</code> - 63,000 rows on the \"server\", the grid\n holds only what you expand.\n </p>\n <SvRowGroupPanel columns={groupCols} groupBy={view?.groupBy ?? []} onChange={(g) => ctl.setGroupBy(g)} />\n <div class=\"grid\"><SvGrid responsive={true} data={rows} {columns} {features} serverGroup={nav} /></div>\n</div>\n\n<style>\n .wrap { display: flex; flex-direction: column; gap: 10px; height: 100%; min-height: 0; }\n .hint { font-size: 13px; color: var(--sg-muted, #64748b); margin: 0; }\n .grid { flex: 1; min-height: 0; }\n</style>\n"
1641
1641
  },
1642
1642
  {
1643
1643
  "id": "345-kanban-pipeline",
@@ -1959,7 +1959,7 @@ export const examples = [
1959
1959
  "path": "examples/src/demos/394-scheduler-heatmap.svelte",
1960
1960
  "title": "Scheduler Heatmap",
1961
1961
  "blurb": "394. Utilization heatmap (Enterprise Scheduler Pro) A support centre where each queue has a capacity (its team size) and calls overlap on it through the day. The row BACKGROUND is tinted by how loaded the",
1962
- "source": "<script lang=\"ts\">\n /**\n * 394. Utilization heatmap (Enterprise Scheduler Pro)\n * ---------------------------------------------------\n * A support centre where each queue has a capacity (its team size) and calls\n * overlap on it through the day. The row BACKGROUND is tinted by how loaded the\n * queue is each hour - light when quiet, hot when near capacity, red when over.\n * Distinct from the histogram: it colours the whole lane, so a glance shows where\n * the pressure is. Toggle to the Table - same grid rows. Renderer: @svgrid/enterprise.\n */\n import { SvGrid, type ColumnDef, type SchedulerResource, type SchedulerEventMoveEvent, type SchedulerEventResizeEvent } from '@svgrid/grid'\n import { enableSchedulerView, setLicenseKey, type SchedulerProConfig } from '@svgrid/enterprise'\n\n setLicenseKey('SVENTERPRISE-DEV-LOCAL')\n enableSchedulerView()\n\n type Queue = SchedulerResource & { cap: number }\n const queues: Queue[] = [\n { id: 'front', title: 'Frontline (4)', color: '#4f46e5', cap: 4 },\n { id: 'billing', title: 'Billing (2)', color: '#0891b2', cap: 2 },\n { id: 'tech', title: 'Tech support (3)', color: '#16a34a', cap: 3 },\n ]\n const queueColor = Object.fromEntries(queues.map((q) => [q.id, q.color!]))\n\n type Call = { id: string; title: string; queue: string; start: string; end: string; color: string }\n const pad = (n: number) => String(n).padStart(2, '0')\n const iso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`\n const today = new Date(); today.setHours(0, 0, 0, 0)\n const at = (h: number, m = 0) => { const d = new Date(today); d.setHours(h, m, 0, 0); return iso(d) }\n let n = 0\n const mk = (queue: string, sh: number, sm: number, eh: number, em: number): Call => ({ id: `c${++n}`, title: `Call ${n}`, queue, start: at(sh, sm), end: at(eh, em), color: queueColor[queue] ?? '#64748b' })\n\n let rows = $state<Call[]>([\n // Frontline: morning surge (5 calls touching 9-11 -> over its cap of 4), then eases.\n mk('front', 9, 0, 10, 30), mk('front', 9, 0, 11, 0), mk('front', 9, 30, 11, 0), mk('front', 9, 30, 10, 30), mk('front', 9, 15, 10, 45),\n mk('front', 10, 0, 11, 0),\n mk('front', 13, 0, 14, 0), mk('front', 13, 30, 14, 30), mk('front', 15, 0, 16, 0),\n // Billing: two overlapping late morning (at cap 2), one afternoon.\n mk('billing', 10, 30, 12, 0), mk('billing', 11, 0, 12, 30), mk('billing', 14, 0, 15, 0),\n // Tech: steady load around cap 3 midday.\n mk('tech', 9, 30, 11, 0), mk('tech', 10, 0, 11, 30), mk('tech', 10, 30, 12, 0), mk('tech', 13, 0, 14, 30), mk('tech', 15, 0, 16, 30),\n ])\n\n const columns: ColumnDef<any, Call>[] = [\n { field: 'title', header: 'Call', editorType: 'text', width: 130 },\n { field: 'queue', header: 'Queue', editorType: 'list', editorOptions: queues.map((q) => ({ value: q.id, label: q.title ?? q.id, color: q.color })), width: 150 },\n { field: 'start', header: 'Start', editorType: 'datetime', width: 150 },\n { field: 'end', header: 'End', editorType: 'datetime', width: 150 },\n ]\n\n function onEventMove(e: SchedulerEventMoveEvent<Call>) { e.row.start = iso(e.start); e.row.end = iso(e.end); if (e.toResource != null) e.row.queue = e.toResource }\n function onEventResize(e: SchedulerEventResizeEvent<Call>) { e.row.start = iso(e.start); e.row.end = iso(e.end) }\n\n let view = $state<'timeline' | 'table'>('timeline')\n\n const schedulerCfg: SchedulerProConfig<any, Call> = {\n startField: 'start', endField: 'end', titleField: 'title', colorField: 'color',\n resourceField: 'queue', resources: queues,\n utilizationHeatmap: { capacityField: 'cap' },\n views: ['timelineDay'], initialView: 'timelineDay',\n businessHours: { start: 8, end: 18 }, dayStartHour: 8, dayEndHour: 18, timelineTickMinWidth: 120, timelineLaneHeight: 22,\n collisionMode: 'stack',\n editable: true, tooltip: true, drawer: true,\n onEventMove, onEventResize,\n }\n</script>\n\n<section class=\"hm\">\n <header class=\"hm-head\">\n <div class=\"hm-title\">\n <strong>Support load</strong>\n <span class=\"hm-sub\">Row background shows each queue's utilization per hour - hot near capacity, red when over</span>\n </div>\n <div class=\"hm-seg\" role=\"tablist\" aria-label=\"View\">\n <button class=\"hm-seg-btn\" role=\"tab\" aria-selected={view === 'timeline'} class:hm-on={view === 'timeline'} onclick={() => (view = 'timeline')}>Timeline</button>\n <button class=\"hm-seg-btn\" role=\"tab\" aria-selected={view === 'table'} class:hm-on={view === 'table'} onclick={() => (view = 'table')}>Table</button>\n </div>\n </header>\n <div class=\"hm-body\">\n {#if view === 'timeline'}\n <SvGrid data={rows} columns={columns} getRowId={(r) => r.id} containerHeight=\"100%\" scheduler={schedulerCfg} />\n {:else}\n <SvGrid data={rows} columns={columns} getRowId={(r) => r.id} containerHeight=\"100%\" editable showPagination={false} />\n {/if}\n </div>\n</section>\n\n<style>\n .hm { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 12px; overflow: hidden; background: var(--sg-bg, #fff); }\n .hm-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--sg-border, #e5e7eb); }\n .hm-title { display: flex; flex-direction: column; gap: 2px; }\n .hm-sub { font-size: 0.78rem; color: var(--sg-muted, #6b7280); }\n .hm-seg { display: inline-flex; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 8px; overflow: hidden; }\n .hm-seg-btn { border: 0; background: var(--sg-bg, #fff); color: var(--sg-muted, #6b7280); font: inherit; font-size: 0.82rem; padding: 5px 12px; cursor: pointer; }\n .hm-seg-btn.hm-on { background: var(--sg-accent, #4f46e5); color: var(--sg-on-accent, #fff); }\n .hm-body { flex: 1 1 auto; min-height: 0; padding: 8px; }\n</style>\n"
1962
+ "source": "<script lang=\"ts\">\n /**\n * 394. Utilization heatmap (Enterprise Scheduler Pro)\n * ---------------------------------------------------\n * A support centre where each queue has a capacity (its team size) and calls\n * overlap on it through the day. The row BACKGROUND is tinted by how loaded the\n * queue is each hour - light when quiet, hot when near capacity, red when over.\n * Distinct from the histogram: it colours the whole lane, so a glance shows where\n * the pressure is. Toggle to the Table - same grid rows. Renderer: @svgrid/enterprise.\n */\n import { SvGrid, type ColumnDef, type SchedulerResource, type SchedulerEventMoveEvent, type SchedulerEventResizeEvent } from '@svgrid/grid'\n import { enableSchedulerView, setLicenseKey, type SchedulerProConfig } from '@svgrid/enterprise'\n\n setLicenseKey('SVENTERPRISE-DEV-LOCAL')\n enableSchedulerView()\n\n type Queue = SchedulerResource & { cap: number }\n const queues: Queue[] = [\n { id: 'front', title: 'Frontline (4)', color: '#4f46e5', cap: 4 },\n { id: 'billing', title: 'Billing (2)', color: '#0891b2', cap: 2 },\n { id: 'tech', title: 'Tech support (3)', color: '#16a34a', cap: 3 },\n ]\n const queueColor = Object.fromEntries(queues.map((q) => [q.id, q.color!]))\n\n type Call = { id: string; title: string; queue: string; start: string; end: string; color: string }\n const pad = (n: number) => String(n).padStart(2, '0')\n const iso = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`\n const today = new Date(); today.setHours(0, 0, 0, 0)\n const at = (h: number, m = 0) => { const d = new Date(today); d.setHours(h, m, 0, 0); return iso(d) }\n let n = 0\n const mk = (queue: string, sh: number, sm: number, eh: number, em: number): Call => ({ id: `c${++n}`, title: `Call ${n}`, queue, start: at(sh, sm), end: at(eh, em), color: queueColor[queue] ?? '#64748b' })\n\n let rows = $state<Call[]>([\n // Frontline: morning surge (5 calls touching 9-11 -> over its cap of 4), then eases.\n mk('front', 9, 0, 10, 30), mk('front', 9, 0, 11, 0), mk('front', 9, 30, 11, 0), mk('front', 9, 30, 10, 30), mk('front', 9, 15, 10, 45),\n mk('front', 10, 0, 11, 0),\n mk('front', 13, 0, 14, 0), mk('front', 13, 30, 14, 30), mk('front', 15, 0, 16, 0),\n // Billing: two overlapping late morning (at cap 2), one afternoon.\n mk('billing', 10, 30, 12, 0), mk('billing', 11, 0, 12, 30), mk('billing', 14, 0, 15, 0),\n // Tech: steady load around cap 3 midday.\n mk('tech', 9, 30, 11, 0), mk('tech', 10, 0, 11, 30), mk('tech', 10, 30, 12, 0), mk('tech', 13, 0, 14, 30), mk('tech', 15, 0, 16, 30),\n ])\n\n const columns: ColumnDef<any, Call>[] = [\n { field: 'title', header: 'Call', editorType: 'text', width: 130 },\n { field: 'queue', header: 'Queue', editorType: 'list', editorOptions: queues.map((q) => ({ value: q.id, label: q.title ?? q.id, color: q.color })), width: 150 },\n { field: 'start', header: 'Start', editorType: 'datetime', width: 150 },\n { field: 'end', header: 'End', editorType: 'datetime', width: 150 },\n ]\n\n function onEventMove(e: SchedulerEventMoveEvent<Call>) { e.row.start = iso(e.start); e.row.end = iso(e.end); if (e.toResource != null) e.row.queue = e.toResource }\n function onEventResize(e: SchedulerEventResizeEvent<Call>) { e.row.start = iso(e.start); e.row.end = iso(e.end) }\n\n let view = $state<'timeline' | 'table'>('timeline')\n\n // `utilizationHeatmap.capacityField` is declared as `keyof SchedulerResource`, so\n // it does not know about a resource type's own extra fields. The renderer reads\n // any numeric field off the resource, so widen the key to this demo's Queue.\n type QueueSchedulerConfig = Omit<SchedulerProConfig<any, Call>, 'utilizationHeatmap'> & {\n utilizationHeatmap?: boolean | { capacityField?: keyof Queue & string }\n }\n\n const schedulerCfg: QueueSchedulerConfig = {\n startField: 'start', endField: 'end', titleField: 'title', colorField: 'color',\n resourceField: 'queue', resources: queues,\n utilizationHeatmap: { capacityField: 'cap' },\n views: ['timelineDay'], initialView: 'timelineDay',\n businessHours: { start: 8, end: 18 }, dayStartHour: 8, dayEndHour: 18, timelineTickMinWidth: 120, timelineLaneHeight: 22,\n collisionMode: 'stack',\n editable: true, tooltip: true, drawer: true,\n onEventMove, onEventResize,\n }\n</script>\n\n<section class=\"hm\">\n <header class=\"hm-head\">\n <div class=\"hm-title\">\n <strong>Support load</strong>\n <span class=\"hm-sub\">Row background shows each queue's utilization per hour - hot near capacity, red when over</span>\n </div>\n <div class=\"hm-seg\" role=\"tablist\" aria-label=\"View\">\n <button class=\"hm-seg-btn\" role=\"tab\" aria-selected={view === 'timeline'} class:hm-on={view === 'timeline'} onclick={() => (view = 'timeline')}>Timeline</button>\n <button class=\"hm-seg-btn\" role=\"tab\" aria-selected={view === 'table'} class:hm-on={view === 'table'} onclick={() => (view = 'table')}>Table</button>\n </div>\n </header>\n <div class=\"hm-body\">\n {#if view === 'timeline'}\n <SvGrid data={rows} columns={columns} getRowId={(r) => r.id} containerHeight=\"100%\" scheduler={schedulerCfg} />\n {:else}\n <SvGrid data={rows} columns={columns} getRowId={(r) => r.id} containerHeight=\"100%\" editable showPagination={false} />\n {/if}\n </div>\n</section>\n\n<style>\n .hm { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 12px; overflow: hidden; background: var(--sg-bg, #fff); }\n .hm-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border-bottom: 1px solid var(--sg-border, #e5e7eb); }\n .hm-title { display: flex; flex-direction: column; gap: 2px; }\n .hm-sub { font-size: 0.78rem; color: var(--sg-muted, #6b7280); }\n .hm-seg { display: inline-flex; border: 1px solid var(--sg-border, #e5e7eb); border-radius: 8px; overflow: hidden; }\n .hm-seg-btn { border: 0; background: var(--sg-bg, #fff); color: var(--sg-muted, #6b7280); font: inherit; font-size: 0.82rem; padding: 5px 12px; cursor: pointer; }\n .hm-seg-btn.hm-on { background: var(--sg-accent, #4f46e5); color: var(--sg-on-accent, #fff); }\n .hm-body { flex: 1 1 auto; min-height: 0; padding: 8px; }\n</style>\n"
1963
1963
  },
1964
1964
  {
1965
1965
  "id": "395-scheduler-booking-rules",
@@ -2043,7 +2043,7 @@ export const examples = [
2043
2043
  "path": "examples/src/demos/404-form-rich-fields.svelte",
2044
2044
  "title": "Form Rich Fields",
2045
2045
  "blurb": "SvForm rich field types - a schema-driven form that now reaches the whole input suite: phone, country, mask, combobox, radio, slider, tags, datetime and file, plus per-field `help` text, a `readonly` field and column `span`.",
2046
- "source": "<script lang=\"ts\">\n /**\n * SvForm rich field types - a schema-driven form that now reaches the whole\n * input suite: phone, country, mask, combobox, radio, slider, tags, datetime\n * and file, plus per-field `help` text, a `readonly` field and column `span`.\n */\n import { SvForm, SvToaster, toast, type FormField } from '@svgrid/grid'\n\n const roles = [\n { value: 'admin', label: 'Administrator' },\n { value: 'editor', label: 'Editor' },\n { value: 'viewer', label: 'Viewer' },\n ]\n const plans = [\n { value: 'free', label: 'Free' },\n { value: 'pro', label: 'Pro' },\n { value: 'business', label: 'Business' },\n { value: 'enterprise', label: 'Enterprise' },\n ]\n\n const fields: FormField[] = [\n { name: 'name', label: 'Full name', required: true, span: 2 },\n { name: 'accountId', label: 'Account ID', type: 'text', readonly: true, help: 'Assigned automatically - cannot be changed.' },\n { name: 'phone', label: 'Phone', type: 'phone' },\n { name: 'country', label: 'Country', type: 'country' },\n { name: 'ssn', label: 'Tax ID', type: 'mask', mask: '###-##-####', placeholder: '000-00-0000', help: 'US format; the raw digits are stored.' },\n { name: 'role', label: 'Role', type: 'radio', options: roles, required: true },\n { name: 'plan', label: 'Plan', type: 'combobox', options: plans, placeholder: 'Choose a plan', required: true },\n { name: 'seats', label: 'Seats', type: 'slider', min: 1, max: 100, step: 1, help: 'Drag to set the seat count.' },\n { name: 'tags', label: 'Tags', type: 'tags', placeholder: 'Add a tag', span: 2 },\n { name: 'starts', label: 'Start date', type: 'datetime', span: 2 },\n { name: 'avatar', label: 'Avatar', type: 'file', accept: 'image/*', span: 2 },\n { name: 'notes', label: 'Notes', type: 'textarea', span: 2 },\n ]\n\n const initial = { accountId: 'ACC-4821', seats: 10, role: 'editor' }\n\n function onSubmit(values: Record<string, unknown>) {\n return toast.promise(new Promise((res) => setTimeout(res, 900)), {\n loading: 'Creating account...', success: `Account created for ${values.name}`, error: 'Failed',\n })\n }\n</script>\n\n<div class=\"wrap\">\n <h2>New account</h2>\n <p class=\"muted\">Rich field types on a single SvForm schema.</p>\n <SvForm {fields} {initial} columns={2} submitLabel=\"Create account\" errorSummary {onSubmit} />\n</div>\n\n<SvToaster position=\"bottom-right\" />\n\n<style>\n .wrap { padding: 22px; max-width: 640px; }\n h2 { margin: 0; font-size: 19px; font-weight: 700; }\n .muted { margin: 3px 0 16px; color: var(--sg-muted, #64748b); font-size: 13px; }\n</style>\n"
2046
+ "source": "<script lang=\"ts\">\n /**\n * SvForm rich field types - a schema-driven form that now reaches the whole\n * input suite: phone, country, mask, combobox, radio, slider, tags, datetime\n * and file, plus per-field `help` text, a `readonly` field and column `span`.\n */\n import { SvForm, SvToaster, toast, type FormField } from '@svgrid/grid'\n\n const roles = [\n { value: 'admin', label: 'Administrator' },\n { value: 'editor', label: 'Editor' },\n { value: 'viewer', label: 'Viewer' },\n ]\n const plans = [\n { value: 'free', label: 'Free' },\n { value: 'pro', label: 'Pro' },\n { value: 'business', label: 'Business' },\n { value: 'enterprise', label: 'Enterprise' },\n ]\n\n const fields: FormField[] = [\n { name: 'name', label: 'Full name', required: true, span: 2 },\n { name: 'accountId', label: 'Account ID', type: 'text', readonly: true, help: 'Assigned automatically - cannot be changed.' },\n { name: 'phone', label: 'Phone', type: 'phone' },\n { name: 'country', label: 'Country', type: 'country' },\n { name: 'ssn', label: 'Tax ID', type: 'mask', mask: '###-##-####', placeholder: '000-00-0000', help: 'US format; the raw digits are stored.' },\n { name: 'role', label: 'Role', type: 'radio', options: roles, required: true },\n { name: 'plan', label: 'Plan', type: 'combobox', options: plans, placeholder: 'Choose a plan', required: true },\n { name: 'seats', label: 'Seats', type: 'slider', min: 1, max: 100, step: 1, help: 'Drag to set the seat count.' },\n { name: 'tags', label: 'Tags', type: 'tags', placeholder: 'Add a tag', span: 2 },\n { name: 'starts', label: 'Start date', type: 'datetime', span: 2 },\n { name: 'avatar', label: 'Avatar', type: 'file', accept: 'image/*', span: 2 },\n { name: 'notes', label: 'Notes', type: 'textarea', span: 2 },\n ]\n\n const initial = { accountId: 'ACC-4821', seats: 10, role: 'editor' }\n\n // Awaited (not returned) so the handler stays `Promise<void>` - SvForm keeps the\n // Submit button in its loading state until the toast promise settles.\n async function onSubmit(values: Record<string, unknown>) {\n await toast.promise(new Promise((res) => setTimeout(res, 900)), {\n loading: 'Creating account...', success: `Account created for ${values.name}`, error: 'Failed',\n })\n }\n</script>\n\n<div class=\"wrap\">\n <h2>New account</h2>\n <p class=\"muted\">Rich field types on a single SvForm schema.</p>\n <SvForm {fields} {initial} columns={2} submitLabel=\"Create account\" errorSummary {onSubmit} />\n</div>\n\n<SvToaster position=\"bottom-right\" />\n\n<style>\n .wrap { padding: 22px; max-width: 640px; }\n h2 { margin: 0; font-size: 19px; font-weight: 700; }\n .muted { margin: 3px 0 16px; color: var(--sg-muted, #64748b); font-size: 13px; }\n</style>\n"
2047
2047
  },
2048
2048
  {
2049
2049
  "id": "405-form-cascading",
@@ -2099,7 +2099,7 @@ export const examples = [
2099
2099
  "path": "examples/src/demos/411-resizable-dropdowns.svelte",
2100
2100
  "title": "Resizable Dropdowns",
2101
2101
  "blurb": "Resizable dropdown panels - the drop-down list, combo box and autocomplete input all take a `resizable` prop that adds a bottom drag grip (\"Ā·Ā·Ā·Ā·\") to their open panel. Drag it to grow or shrink the list; the choice sticks for",
2102
- "source": "<script lang=\"ts\">\n /**\n * Resizable dropdown panels - the drop-down list, combo box and autocomplete\n * input all take a `resizable` prop that adds a bottom drag grip (\"Ā·Ā·Ā·Ā·\") to\n * their open panel. Drag it to grow or shrink the list; the choice sticks for\n * the session. The grip hides automatically when a panel flips upward (no room\n * to grow below). All themed from the --sg-* tokens.\n */\n import {\n SvDropDownList, SvComboBox, SvAutoComplete,\n SvStack, SvGroup, SvText, SvTitle, SvSwitchButton,\n } from '@svgrid/grid'\n\n // A long-ish list so there is something to scroll / resize into.\n const timezones = [\n 'UTC-12:00 Baker Island', 'UTC-11:00 Niue', 'UTC-10:00 Honolulu', 'UTC-09:00 Anchorage',\n 'UTC-08:00 Los Angeles', 'UTC-07:00 Denver', 'UTC-06:00 Chicago', 'UTC-05:00 New York',\n 'UTC-04:00 Santiago', 'UTC-03:00 Sao Paulo', 'UTC-02:00 South Georgia', 'UTC-01:00 Azores',\n 'UTC+00:00 London', 'UTC+01:00 Berlin', 'UTC+02:00 Cairo', 'UTC+03:00 Moscow',\n 'UTC+03:30 Tehran', 'UTC+04:00 Dubai', 'UTC+05:00 Karachi', 'UTC+05:30 Mumbai',\n 'UTC+06:00 Dhaka', 'UTC+07:00 Bangkok', 'UTC+08:00 Singapore', 'UTC+09:00 Tokyo',\n 'UTC+09:30 Adelaide', 'UTC+10:00 Sydney', 'UTC+11:00 Noumea', 'UTC+12:00 Auckland',\n ].map((label, i) => ({ value: i, label }))\n\n const fruits = [\n 'Apple', 'Apricot', 'Avocado', 'Banana', 'Blackberry', 'Blueberry', 'Cherry', 'Clementine',\n 'Coconut', 'Cranberry', 'Date', 'Dragonfruit', 'Elderberry', 'Fig', 'Gooseberry', 'Grape',\n 'Grapefruit', 'Guava', 'Kiwi', 'Lemon', 'Lime', 'Lychee', 'Mango', 'Melon', 'Nectarine',\n 'Orange', 'Papaya', 'Passionfruit', 'Peach', 'Pear', 'Pineapple', 'Plum', 'Pomegranate',\n 'Raspberry', 'Strawberry', 'Tangerine', 'Watermelon',\n ].map((f) => ({ value: f.toLowerCase(), label: f }))\n\n const cities = [\n 'Amsterdam', 'Athens', 'Bangkok', 'Barcelona', 'Berlin', 'Boston', 'Brussels', 'Budapest',\n 'Chicago', 'Copenhagen', 'Dubai', 'Dublin', 'Helsinki', 'Istanbul', 'Lisbon', 'London',\n 'Madrid', 'Melbourne', 'Miami', 'Milan', 'Montreal', 'Munich', 'New York', 'Oslo', 'Paris',\n 'Prague', 'Rome', 'Seattle', 'Singapore', 'Stockholm', 'Sydney', 'Tokyo', 'Toronto', 'Vienna',\n 'Warsaw', 'Zurich',\n ]\n\n let tz = $state<number | null>(12)\n let fruit = $state<string | number | null>(null)\n let city = $state('')\n let resizable = $state(true)\n</script>\n\n<div class=\"page\">\n <SvStack gap={6}>\n <SvTitle order={2}>Resizable + bounds-aware dropdown panels</SvTitle>\n <SvText tone=\"muted\">\n Every picker panel opens downward when there is room and flips upward\n automatically when there is not (browser bounds detection - try it near the\n bottom of the window below). With <code>resizable</code>, the open panel also\n grows a bottom drag grip; drag it to resize, and the height sticks for the\n session. Toggle the switch to compare with the fixed-height default.\n </SvText>\n </SvStack>\n\n <SvGroup gap={10} align=\"center\" wrap>\n <SvSwitchButton checked={resizable} onChange={(v) => (resizable = v)} />\n <SvText size=\"sm\">{resizable ? 'Resizable panels (drag the grip)' : 'Fixed-height panels'}</SvText>\n </SvGroup>\n\n <SvGroup gap={28} align=\"start\" wrap>\n <SvStack gap={6}>\n <SvText size=\"sm\" weight=\"semibold\">Drop-down list</SvText>\n <SvDropDownList\n {resizable}\n bind:value={tz}\n options={timezones}\n placeholder=\"Pick a timezone\"\n label=\"Timezone\"\n hint=\"28 options - grow the panel to see more at once\"\n />\n </SvStack>\n\n <SvStack gap={6}>\n <SvText size=\"sm\" weight=\"semibold\">Combo box</SvText>\n <SvComboBox\n {resizable}\n clearable\n bind:value={fruit}\n options={fruits}\n placeholder=\"Type to filter…\"\n label=\"Favourite fruit\"\n hint=\"Filter, then resize the results\"\n />\n </SvStack>\n\n <SvStack gap={6}>\n <SvText size=\"sm\" weight=\"semibold\">Autocomplete input</SvText>\n <SvAutoComplete\n {resizable}\n bind:value={city}\n suggestions={cities}\n placeholder=\"Search a city…\"\n label=\"City\"\n hint=\"Free text + suggestions\"\n />\n </SvStack>\n </SvGroup>\n\n <div class=\"readout\">\n <SvText size=\"sm\" tone=\"muted\">\n Selected - timezone: <b>{timezones.find((t) => t.value === tz)?.label ?? '-'}</b>,\n fruit: <b>{fruit ?? '-'}</b>, city: <b>{city || '-'}</b>\n </SvText>\n </div>\n\n <!-- Bounds detection: this row sits low in a tall page, so opening it near the\n bottom of the window flips the panel UPWARD automatically. -->\n <div class=\"bounds\">\n <SvText size=\"sm\" weight=\"semibold\">Bounds detection (open near the window's bottom edge)</SvText>\n <SvText size=\"sm\" tone=\"muted\">\n This picker sits low on the page. Open it - with little room below, the panel\n opens <b>upward</b> instead of being clipped. Scroll so it is near the top and\n it opens downward again. No configuration; it is built in.\n </SvText>\n <div class=\"bounds-field\">\n <SvDropDownList\n {resizable}\n bind:value={tz}\n options={timezones}\n placeholder=\"Pick a timezone\"\n ariaLabel=\"Timezone (bounds demo)\"\n />\n </div>\n </div>\n</div>\n\n<style>\n .page { padding: 24px; max-width: 760px; display: flex; flex-direction: column; gap: 22px; }\n /* --sg-header-bg is theme-defined (light + dark); --sg-muted-bg is not, so it\n would fall back to a fixed light colour and clash in dark mode. */\n .readout { padding: 12px 14px; border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px; background: var(--sg-header-bg, #f8fafc); }\n b { color: var(--sg-fg, #0f172a); font-weight: 600; }\n .bounds {\n display: flex; flex-direction: column; gap: 8px;\n margin-top: 40vh; /* push it low so opening it demonstrates the upward flip */\n padding: 14px; border: 1px dashed var(--sg-border, #e2e8f0); border-radius: 10px;\n background: var(--sg-header-bg, #f8fafc);\n }\n .bounds-field { margin-top: 2px; }\n</style>\n"
2102
+ "source": "<script lang=\"ts\">\n /**\n * Resizable dropdown panels - the drop-down list, combo box and autocomplete\n * input all take a `resizable` prop that adds a bottom drag grip (\"Ā·Ā·Ā·Ā·\") to\n * their open panel. Drag it to grow or shrink the list; the choice sticks for\n * the session. The grip hides automatically when a panel flips upward (no room\n * to grow below). All themed from the --sg-* tokens.\n */\n import {\n SvDropDownList, SvComboBox, SvAutoComplete,\n SvStack, SvGroup, SvText, SvTitle, SvSwitchButton,\n } from '@svgrid/grid'\n\n // A long-ish list so there is something to scroll / resize into.\n const timezones = [\n 'UTC-12:00 Baker Island', 'UTC-11:00 Niue', 'UTC-10:00 Honolulu', 'UTC-09:00 Anchorage',\n 'UTC-08:00 Los Angeles', 'UTC-07:00 Denver', 'UTC-06:00 Chicago', 'UTC-05:00 New York',\n 'UTC-04:00 Santiago', 'UTC-03:00 Sao Paulo', 'UTC-02:00 South Georgia', 'UTC-01:00 Azores',\n 'UTC+00:00 London', 'UTC+01:00 Berlin', 'UTC+02:00 Cairo', 'UTC+03:00 Moscow',\n 'UTC+03:30 Tehran', 'UTC+04:00 Dubai', 'UTC+05:00 Karachi', 'UTC+05:30 Mumbai',\n 'UTC+06:00 Dhaka', 'UTC+07:00 Bangkok', 'UTC+08:00 Singapore', 'UTC+09:00 Tokyo',\n 'UTC+09:30 Adelaide', 'UTC+10:00 Sydney', 'UTC+11:00 Noumea', 'UTC+12:00 Auckland',\n ].map((label, i) => ({ value: i, label }))\n\n const fruits = [\n 'Apple', 'Apricot', 'Avocado', 'Banana', 'Blackberry', 'Blueberry', 'Cherry', 'Clementine',\n 'Coconut', 'Cranberry', 'Date', 'Dragonfruit', 'Elderberry', 'Fig', 'Gooseberry', 'Grape',\n 'Grapefruit', 'Guava', 'Kiwi', 'Lemon', 'Lime', 'Lychee', 'Mango', 'Melon', 'Nectarine',\n 'Orange', 'Papaya', 'Passionfruit', 'Peach', 'Pear', 'Pineapple', 'Plum', 'Pomegranate',\n 'Raspberry', 'Strawberry', 'Tangerine', 'Watermelon',\n ].map((f) => ({ value: f.toLowerCase(), label: f }))\n\n const cities = [\n 'Amsterdam', 'Athens', 'Bangkok', 'Barcelona', 'Berlin', 'Boston', 'Brussels', 'Budapest',\n 'Chicago', 'Copenhagen', 'Dubai', 'Dublin', 'Helsinki', 'Istanbul', 'Lisbon', 'London',\n 'Madrid', 'Melbourne', 'Miami', 'Milan', 'Montreal', 'Munich', 'New York', 'Oslo', 'Paris',\n 'Prague', 'Rome', 'Seattle', 'Singapore', 'Stockholm', 'Sydney', 'Tokyo', 'Toronto', 'Vienna',\n 'Warsaw', 'Zurich',\n ]\n\n let tz = $state<string | number | null>(12)\n let fruit = $state<string | number | null>(null)\n let city = $state('')\n let resizable = $state(true)\n</script>\n\n<div class=\"page\">\n <SvStack gap={6}>\n <SvTitle order={2}>Resizable + bounds-aware dropdown panels</SvTitle>\n <SvText tone=\"muted\">\n Every picker panel opens downward when there is room and flips upward\n automatically when there is not (browser bounds detection - try it near the\n bottom of the window below). With <code>resizable</code>, the open panel also\n grows a bottom drag grip; drag it to resize, and the height sticks for the\n session. Toggle the switch to compare with the fixed-height default.\n </SvText>\n </SvStack>\n\n <SvGroup gap={10} align=\"center\" wrap>\n <SvSwitchButton checked={resizable} onChange={(v) => (resizable = v)} />\n <SvText size=\"sm\">{resizable ? 'Resizable panels (drag the grip)' : 'Fixed-height panels'}</SvText>\n </SvGroup>\n\n <SvGroup gap={28} align=\"start\" wrap>\n <SvStack gap={6}>\n <SvText size=\"sm\" weight=\"semibold\">Drop-down list</SvText>\n <SvDropDownList\n {resizable}\n value={tz}\n onChange={(v) => (tz = v)}\n options={timezones}\n placeholder=\"Pick a timezone\"\n label=\"Timezone\"\n hint=\"28 options - grow the panel to see more at once\"\n />\n </SvStack>\n\n <SvStack gap={6}>\n <SvText size=\"sm\" weight=\"semibold\">Combo box</SvText>\n <SvComboBox\n {resizable}\n clearable\n value={fruit}\n onChange={(v) => (fruit = v)}\n options={fruits}\n placeholder=\"Type to filter…\"\n label=\"Favourite fruit\"\n hint=\"Filter, then resize the results\"\n />\n </SvStack>\n\n <SvStack gap={6}>\n <SvText size=\"sm\" weight=\"semibold\">Autocomplete input</SvText>\n <SvAutoComplete\n {resizable}\n bind:value={city}\n suggestions={cities}\n placeholder=\"Search a city…\"\n label=\"City\"\n hint=\"Free text + suggestions\"\n />\n </SvStack>\n </SvGroup>\n\n <div class=\"readout\">\n <SvText size=\"sm\" tone=\"muted\">\n Selected - timezone: <b>{timezones.find((t) => t.value === tz)?.label ?? '-'}</b>,\n fruit: <b>{fruit ?? '-'}</b>, city: <b>{city || '-'}</b>\n </SvText>\n </div>\n\n <!-- Bounds detection: this row sits low in a tall page, so opening it near the\n bottom of the window flips the panel UPWARD automatically. -->\n <div class=\"bounds\">\n <SvText size=\"sm\" weight=\"semibold\">Bounds detection (open near the window's bottom edge)</SvText>\n <SvText size=\"sm\" tone=\"muted\">\n This picker sits low on the page. Open it - with little room below, the panel\n opens <b>upward</b> instead of being clipped. Scroll so it is near the top and\n it opens downward again. No configuration; it is built in.\n </SvText>\n <div class=\"bounds-field\">\n <SvDropDownList\n {resizable}\n value={tz}\n onChange={(v) => (tz = v)}\n options={timezones}\n placeholder=\"Pick a timezone\"\n ariaLabel=\"Timezone (bounds demo)\"\n />\n </div>\n </div>\n</div>\n\n<style>\n .page { padding: 24px; max-width: 760px; display: flex; flex-direction: column; gap: 22px; }\n /* --sg-header-bg is theme-defined (light + dark); --sg-muted-bg is not, so it\n would fall back to a fixed light colour and clash in dark mode. */\n .readout { padding: 12px 14px; border: 1px solid var(--sg-border, #e2e8f0); border-radius: 10px; background: var(--sg-header-bg, #f8fafc); }\n b { color: var(--sg-fg, #0f172a); font-weight: 600; }\n .bounds {\n display: flex; flex-direction: column; gap: 8px;\n margin-top: 40vh; /* push it low so opening it demonstrates the upward flip */\n padding: 14px; border: 1px dashed var(--sg-border, #e2e8f0); border-radius: 10px;\n background: var(--sg-header-bg, #f8fafc);\n }\n .bounds-field { margin-top: 2px; }\n</style>\n"
2103
2103
  },
2104
2104
  {
2105
2105
  "id": "42-logistics-fleet",
@@ -2150,6 +2150,27 @@ export const examples = [
2150
2150
  "blurb": "Pricing block - a three-tier plan grid with a monthly/annual SvSegmented toggle, a \"most popular\" highlighted card, per-plan feature SvList with check marks, and a CTA SvButton on each. Annual applies a discount live.",
2151
2151
  "source": "<script lang=\"ts\">\r\n /**\r\n * Pricing block - a three-tier plan grid with a monthly/annual SvSegmented\r\n * toggle, a \"most popular\" highlighted card, per-plan feature SvList with\r\n * check marks, and a CTA SvButton on each. Annual applies a discount live.\r\n * Pure UI-kit composition, --sg-* themed.\r\n */\r\n import {\r\n SvCard, SvSegmented, SvButton, SvBadge, SvList, SvDivider, SvToaster, toast,\r\n } from '@svgrid/grid'\r\n\r\n let cycle = $state<string | number>('annual')\r\n const annual = $derived(cycle === 'annual')\r\n\r\n type Plan = {\r\n id: string; name: string; blurb: string; monthly: number;\r\n popular?: boolean; features: string[]; cta: string\r\n }\r\n const plans: Plan[] = [\r\n {\r\n id: 'starter', name: 'Starter', blurb: 'For side projects and evaluation.',\r\n monthly: 0, cta: 'Start free',\r\n features: ['1 workspace', 'Up to 3 dashboards', 'Community support', '7-day data history'],\r\n },\r\n {\r\n id: 'pro', name: 'Pro', blurb: 'For growing teams shipping fast.', popular: true,\r\n monthly: 29, cta: 'Start 14-day trial',\r\n features: ['Unlimited dashboards', 'Live SQL + REST sources', 'Role-based access', 'Excel / PDF export', 'Priority email support'],\r\n },\r\n {\r\n id: 'enterprise', name: 'Enterprise', blurb: 'For orgs with scale + compliance.',\r\n monthly: 99, cta: 'Contact sales',\r\n features: ['Everything in Pro', 'SSO / SAML + audit log', 'Dedicated support & SLA', 'On-prem / VPC deploy', 'Custom contracts'],\r\n },\r\n ]\r\n\r\n function price(p: Plan) {\r\n if (p.monthly === 0) return '$0'\r\n const v = annual ? Math.round(p.monthly * 0.8) : p.monthly\r\n return '$' + v\r\n }\r\n</script>\r\n\r\n<div class=\"pricing\">\r\n <header class=\"head\">\r\n <SvBadge variant=\"accent\" pill>Pricing</SvBadge>\r\n <h1>Plans that scale with you.</h1>\r\n <p class=\"muted\">Start free, upgrade when you grow. No hidden fees.</p>\r\n <div class=\"toggle\">\r\n <SvSegmented\r\n bind:value={cycle}\r\n options={[\r\n { value: 'monthly', label: 'Monthly' },\r\n { value: 'annual', label: 'Annual -20%' },\r\n ]}\r\n />\r\n </div>\r\n </header>\r\n\r\n <div class=\"grid\">\r\n {#each plans as p (p.id)}\r\n <div class=\"plan\" class:popular={p.popular}>\r\n <SvCard>\r\n {#if p.popular}<div class=\"ribbon\"><span class=\"ribbon-pill\">Most popular</span></div>{/if}\r\n <div class=\"plan-head\">\r\n <h2>{p.name}</h2>\r\n <p class=\"muted\">{p.blurb}</p>\r\n </div>\r\n <div class=\"amount\">\r\n <span class=\"num\">{price(p)}</span>\r\n {#if p.monthly > 0}<span class=\"per\">/mo{annual ? ', billed yearly' : ''}</span>{/if}\r\n </div>\r\n <SvButton variant={p.popular ? 'primary' : 'outline'} block onclick={() => toast(`${p.cta} - ${p.name}`)}>\r\n {p.cta}\r\n </SvButton>\r\n <SvDivider />\r\n <SvList type=\"none\" spacing=\"sm\">\r\n {#each p.features as f (f)}\r\n <li class=\"feat\"><span class=\"tick\">āœ“</span>{f}</li>\r\n {/each}\r\n </SvList>\r\n </SvCard>\r\n </div>\r\n {/each}\r\n </div>\r\n\r\n <p class=\"foot muted\">All plans include SSL, 99.9% uptime and unlimited seats on Pro+. Prices in USD.</p>\r\n</div>\r\n\r\n<SvToaster position=\"bottom-right\" />\r\n\r\n<style>\r\n .pricing { padding: 28px 20px; display: flex; flex-direction: column; gap: 26px; background: var(--sg-header-bg, #f8fafc); color: var(--sg-fg, #0f172a); }\r\n .head { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }\r\n .head h1 { margin: 6px 0 0; font-size: 30px; letter-spacing: -.02em; }\r\n .muted { color: var(--sg-muted, #64748b); font-size: 14px; margin: 0; }\r\n .toggle { margin-top: 10px; }\r\n\r\n .grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; max-width: 940px; margin: 0 auto; width: 100%; align-items: start; }\r\n .plan { position: relative; }\r\n .plan.popular { transform: translateY(-6px); }\r\n .plan.popular :global(.sv-card),\r\n .plan.popular :global([class*='card']) { border-color: var(--sg-accent, #4f46e5); box-shadow: 0 10px 30px -12px rgba(79, 70, 229, .35); }\r\n .ribbon { position: absolute; top: -12px; left: 50%; transform: translateX(-50%); z-index: 1; }\r\n /* Solid pill so the highlighted card's border line can't strike through the\r\n text. White-on-accent reads in both light and dark themes. */\r\n .ribbon-pill { display: inline-block; padding: 4px 12px; border-radius: 999px; background: var(--sg-accent, #4f46e5); color: #fff; font-size: 11.5px; font-weight: 600; line-height: 1.4; white-space: nowrap; box-shadow: 0 2px 8px -2px rgba(0, 0, 0, .35); }\r\n\r\n .plan-head h2 { margin: 0 0 4px; font-size: 19px; }\r\n .amount { display: flex; align-items: baseline; gap: 4px; margin: 16px 0; }\r\n .num { font-size: 38px; font-weight: 700; letter-spacing: -.03em; }\r\n .per { color: var(--sg-muted, #64748b); font-size: 13px; }\r\n\r\n .feat { display: flex; align-items: center; gap: 9px; font-size: 13.5px; padding: 3px 0; }\r\n .tick { display: inline-flex; width: 18px; height: 18px; flex: none; align-items: center; justify-content: center; border-radius: 50%; background: color-mix(in srgb, #22c55e 20%, transparent); color: #22c55e; font-size: 11px; font-weight: 700; }\r\n\r\n .foot { text-align: center; font-size: 12.5px; }\r\n\r\n @media (max-width: 820px) {\r\n .grid { grid-template-columns: 1fr; max-width: 400px; }\r\n .plan.popular { transform: none; }\r\n }\r\n</style>\r\n"
2152
2152
  },
2153
+ {
2154
+ "id": "426-tree-data",
2155
+ "path": "examples/src/demos/426-tree-data.svelte",
2156
+ "title": "Tree Data",
2157
+ "blurb": "426. Tree data `treeData` nests rows into an expandable hierarchy by parent id: treeData={{ parentField: 'managerId', column: 'name' }}",
2158
+ "source": "<!-- Documented in: docs/help/rows/tree-data.md -->\r\n<script lang=\"ts\">\r\n /**\r\n * 426. Tree data\r\n * --------------\r\n * `treeData` nests rows into an expandable hierarchy by parent id:\r\n *\r\n * treeData={{ parentField: 'managerId', column: 'name' }}\r\n *\r\n * Tree rows stay REAL data rows - own cells, formatting, editing, selection -\r\n * and only gain an expander plus indent in the tree column. (Row grouping is\r\n * the other shape: there the parent is a synthetic full-width banner.)\r\n *\r\n * Two input shapes, one model:\r\n * FLAT - rows already carry a parent id. Use it directly.\r\n * NESTED - objects hold a `children` array. `flattenTreeData` stamps\r\n * `__parentId` on each child and returns one flat list.\r\n */\r\n import {\r\n SvGrid,\r\n flattenTreeData,\r\n tableFeatures,\r\n rowSortingFeature,\r\n type ColumnDef,\r\n type SvGridApi,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({ rowSortingFeature })\r\n\r\n // ---- Flat source: an org chart, each row naming its manager -------------\r\n type Person = {\r\n id: number\r\n managerId: number | null\r\n name: string\r\n title: string\r\n location: string\r\n reports: number\r\n budget: number\r\n }\r\n\r\n const people: Person[] = [\r\n { id: 1, managerId: null, name: 'Ada Lovelace', title: 'Chief Executive', location: 'London', reports: 10, budget: 4_200_000 },\r\n { id: 2, managerId: 1, name: 'Grace Hopper', title: 'VP Engineering', location: 'New York', reports: 5, budget: 1_850_000 },\r\n { id: 3, managerId: 2, name: 'Alan Turing', title: 'Principal Engineer', location: 'Cambridge', reports: 2, budget: 540_000 },\r\n { id: 4, managerId: 3, name: 'Barbara Liskov', title: 'Staff Engineer', location: 'Boston', reports: 0, budget: 210_000 },\r\n { id: 5, managerId: 3, name: 'Ken Thompson', title: 'Staff Engineer', location: 'Remote', reports: 0, budget: 205_000 },\r\n { id: 6, managerId: 2, name: 'Dennis Ritchie', title: 'Engineering Manager',location: 'New York', reports: 1, budget: 420_000 },\r\n { id: 7, managerId: 6, name: 'Brian Kernighan', title: 'Senior Engineer', location: 'Princeton', reports: 0, budget: 190_000 },\r\n { id: 8, managerId: 1, name: 'Margaret Hamilton', title: 'VP Operations', location: 'Houston', reports: 2, budget: 1_100_000 },\r\n { id: 9, managerId: 8, name: 'Katherine Johnson', title: 'Operations Lead', location: 'Houston', reports: 1, budget: 380_000 },\r\n { id: 10, managerId: 9, name: 'Dorothy Vaughan', title: 'Operations Engineer',location: 'Houston', reports: 0, budget: 175_000 },\r\n { id: 11, managerId: 1, name: 'Linus Torvalds', title: 'VP Infrastructure', location: 'Portland', reports: 0, budget: 950_000 },\r\n ]\r\n\r\n // ---- Nested source: a file tree with children arrays --------------------\r\n type Node = {\r\n id: number\r\n name: string\r\n title: string\r\n location: string\r\n reports: number\r\n budget: number\r\n children?: Node[]\r\n }\r\n\r\n const fileTree: Node[] = [\r\n {\r\n id: 100, name: 'src', title: 'folder', location: '—', reports: 5, budget: 0,\r\n children: [\r\n {\r\n id: 101, name: 'components', title: 'folder', location: '—', reports: 2, budget: 0,\r\n children: [\r\n { id: 102, name: 'Grid.svelte', title: 'component', location: '18.4 KB', reports: 0, budget: 0 },\r\n { id: 103, name: 'Toolbar.svelte', title: 'component', location: '6.1 KB', reports: 0, budget: 0 },\r\n ],\r\n },\r\n {\r\n id: 104, name: 'lib', title: 'folder', location: '—', reports: 2, budget: 0,\r\n children: [\r\n { id: 105, name: 'utils.ts', title: 'module', location: '3.2 KB', reports: 0, budget: 0 },\r\n { id: 106, name: 'format.ts', title: 'module', location: '1.8 KB', reports: 0, budget: 0 },\r\n ],\r\n },\r\n { id: 107, name: 'main.ts', title: 'entry', location: '0.9 KB', reports: 0, budget: 0 },\r\n ],\r\n },\r\n {\r\n id: 200, name: 'docs', title: 'folder', location: '—', reports: 2, budget: 0,\r\n children: [\r\n { id: 201, name: 'readme.md', title: 'markdown', location: '2.4 KB', reports: 0, budget: 0 },\r\n { id: 202, name: 'changelog.md', title: 'markdown', location: '5.7 KB', reports: 0, budget: 0 },\r\n ],\r\n },\r\n ]\r\n\r\n // One call converts the nested shape into the flat parent-id shape the model\r\n // consumes. `__parentId` is the link field it stamps on by default.\r\n const flattenedFiles = flattenTreeData(fileTree, { childrenField: 'children' })\r\n\r\n type Source = 'org' | 'files'\r\n let source = $state<Source>('org')\r\n\r\n const orgColumns: ColumnDef<typeof features, Person>[] = [\r\n { field: 'name', header: 'Name', width: 240 },\r\n { field: 'title', header: 'Title', width: 190 },\r\n { field: 'location', header: 'Location', width: 130 },\r\n { field: 'reports', header: 'Reports', width: 100, align: 'right' },\r\n {\r\n field: 'budget', header: 'Budget', width: 140, align: 'right',\r\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\r\n },\r\n ]\r\n\r\n const fileColumns: ColumnDef<typeof features, Node>[] = [\r\n { field: 'name', header: 'Name', width: 280 },\r\n { field: 'title', header: 'Kind', width: 140 },\r\n { field: 'location', header: 'Size', width: 120, align: 'right' },\r\n ]\r\n\r\n // `expandAllGroups` / `collapseAllGroups` walk the row model's `subRows`,\r\n // which is exactly what the tree model builds - so they drive a tree too.\r\n let api = $state<SvGridApi<typeof features, Person | Node> | null>(null)\r\n</script>\r\n\r\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\r\n <div\r\n class=\"shrink-0 rounded-lg border px-4 py-3\"\r\n style=\"border-color: var(--sg-border); background: var(--sg-header-bg);\"\r\n >\r\n <p class=\"text-sm font-semibold\" style=\"color: var(--sg-fg);\">\r\n Hierarchical rows via <code>treeData</code>\r\n </p>\r\n <p class=\"mt-1 text-xs\" style=\"color: var(--sg-muted);\">\r\n Click a chevron, or focus a row and press <kbd>→</kbd> / <kbd>←</kbd>. The\r\n grid uses the <code>treegrid</code> role with <code>aria-level</code> and\r\n <code>aria-expanded</code>, so screen readers announce the depth. Rows keep\r\n their own cells - sort by Budget and the hierarchy still holds.\r\n </p>\r\n\r\n <div class=\"mt-3 flex flex-wrap items-center gap-2\">\r\n <div class=\"inline-flex overflow-hidden rounded-md border\" style=\"border-color: var(--sg-border);\">\r\n <button\r\n type=\"button\" class=\"px-3 py-1 text-xs\"\r\n style={source === 'org'\r\n ? 'background: var(--sg-accent, #2563eb); color: var(--sg-on-accent, #fff);'\r\n : 'background: transparent; color: var(--sg-fg);'}\r\n onclick={() => (source = 'org')}\r\n >Flat (parent id)</button>\r\n <button\r\n type=\"button\" class=\"px-3 py-1 text-xs\"\r\n style={source === 'files'\r\n ? 'background: var(--sg-accent, #2563eb); color: var(--sg-on-accent, #fff);'\r\n : 'background: transparent; color: var(--sg-fg);'}\r\n onclick={() => (source = 'files')}\r\n >Nested (flattened)</button>\r\n </div>\r\n\r\n <button\r\n type=\"button\" class=\"rounded-md border px-3 py-1 text-xs\"\r\n style=\"border-color: var(--sg-border); color: var(--sg-fg);\"\r\n onclick={() => api?.expandAllGroups()}\r\n >Expand all</button>\r\n <button\r\n type=\"button\" class=\"rounded-md border px-3 py-1 text-xs\"\r\n style=\"border-color: var(--sg-border); color: var(--sg-fg);\"\r\n onclick={() => api?.collapseAllGroups()}\r\n >Collapse all</button>\r\n\r\n <span class=\"text-xs\" style=\"color: var(--sg-muted);\">\r\n {#if source === 'org'}\r\n <code>parentField: 'managerId'</code>\r\n {:else}\r\n <code>flattenTreeData(tree, &lbrace; childrenField: 'children' &rbrace;)</code>\r\n {/if}\r\n </span>\r\n </div>\r\n </div>\r\n\r\n <div class=\"flex-1 min-h-0\">\r\n {#if source === 'org'}\r\n <SvGrid\r\n responsive={true}\r\n data={people}\r\n columns={orgColumns}\r\n {features}\r\n treeData={{ parentField: 'managerId', column: 'name' }}\r\n selectionMode=\"none\"\r\n enableRowSummaries={false}\r\n rowHeight={34}\r\n containerHeight=\"100%\"\r\n fitColumns={true}\r\n onApiReady={(a) => (api = a as never)}\r\n />\r\n {:else}\r\n <SvGrid\r\n responsive={true}\r\n data={flattenedFiles}\r\n columns={fileColumns}\r\n {features}\r\n treeData={{ parentField: '__parentId', column: 'name' }}\r\n selectionMode=\"none\"\r\n enableRowSummaries={false}\r\n rowHeight={34}\r\n containerHeight=\"100%\"\r\n fitColumns={true}\r\n onApiReady={(a) => (api = a as never)}\r\n />\r\n {/if}\r\n </div>\r\n</section>\r\n"
2159
+ },
2160
+ {
2161
+ "id": "427-group-footers",
2162
+ "path": "examples/src/demos/427-group-footers.svelte",
2163
+ "title": "Group Footers",
2164
+ "blurb": "427. Group display modes + footers Three ways to draw grouped rows, and a subtotal row per group: groupDisplayMode=\"groupRows\" full-width banner per group (default)",
2165
+ "source": "<!-- Documented in: docs/help/grouping-aggregation.md -->\r\n<script lang=\"ts\">\r\n /**\r\n * 427. Group display modes + footers\r\n * ----------------------------------\r\n * Three ways to draw grouped rows, and a subtotal row per group:\r\n *\r\n * groupDisplayMode=\"groupRows\" full-width banner per group (default)\r\n * groupDisplayMode=\"singleColumn\" one combined Group column\r\n * groupDisplayMode=\"multipleColumns\" one column per grouped field\r\n * groupFooters subtotal row closing each group\r\n *\r\n * Grouped by TWO fields (Region, then Tier) - with only one grouping level\r\n * the two column modes are identical by definition.\r\n *\r\n * Paging counts DATA rows: a page holds `pageSize` real rows and reprints the\r\n * banners they sit under, and footers are inserted after paging so they never\r\n * eat the budget.\r\n */\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnGroupingFeature,\r\n type ColumnDef,\r\n type SvGridApi,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({ rowSortingFeature, columnGroupingFeature })\r\n\r\n type Sale = {\r\n id: number\r\n region: string\r\n tier: string\r\n rep: string\r\n deals: number\r\n revenue: number\r\n }\r\n\r\n let seed = 0x5eed\r\n const rnd = () => ((seed = (seed * 1103515245 + 12345) >>> 0) / 0xffffffff)\r\n\r\n const REGIONS = ['Americas', 'EMEA', 'APAC']\r\n const TIERS = ['Enterprise', 'Mid-market']\r\n const REPS = ['Ada L.', 'Grace H.', 'Alan T.', 'Margaret H.', 'Linus T.', 'Donald K.', 'Barbara L.', 'Ken T.']\r\n\r\n const rows: Sale[] = Array.from({ length: 60 }, (_, i) => ({\r\n id: i + 1,\r\n region: REGIONS[i % REGIONS.length]!,\r\n tier: TIERS[Math.floor(i / REGIONS.length) % TIERS.length]!,\r\n rep: REPS[Math.floor(rnd() * REPS.length)]!,\r\n deals: 1 + Math.floor(rnd() * 9),\r\n revenue: Math.round(5_000 + rnd() * 95_000),\r\n }))\r\n\r\n const columns: ColumnDef<typeof features, Sale>[] = [\r\n { field: 'region', header: 'Region', width: 140 },\r\n { field: 'tier', header: 'Tier', width: 140 },\r\n { field: 'rep', header: 'Rep', width: 150 },\r\n { field: 'deals', header: 'Deals', width: 110, align: 'right', aggregate: 'sum' },\r\n {\r\n field: 'revenue', header: 'Revenue', width: 150, align: 'right', aggregate: 'sum',\r\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\r\n },\r\n ]\r\n\r\n type Mode = 'groupRows' | 'singleColumn' | 'multipleColumns'\r\n const MODES: Array<{ id: Mode; label: string; hint: string }> = [\r\n { id: 'groupRows', label: 'groupRows', hint: 'A full-width banner per group. The default.' },\r\n { id: 'singleColumn', label: 'singleColumn', hint: 'One Group column holding both levels, indented by depth.' },\r\n { id: 'multipleColumns', label: 'multipleColumns', hint: 'A column per grouped field - Region and Tier each get one.' },\r\n ]\r\n\r\n let mode = $state<Mode>('groupRows')\r\n let footers = $state(true)\r\n let pageSize = $state(10)\r\n\r\n const activeHint = $derived(MODES.find((m) => m.id === mode)?.hint ?? '')\r\n</script>\r\n\r\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\r\n <div\r\n class=\"shrink-0 rounded-lg border px-4 py-3\"\r\n style=\"border-color: var(--sg-border); background: var(--sg-header-bg);\"\r\n >\r\n <p class=\"text-sm font-semibold\" style=\"color: var(--sg-fg);\">\r\n Grouped by Region → Tier\r\n </p>\r\n <p class=\"mt-1 text-xs\" style=\"color: var(--sg-muted);\">\r\n In the two column modes the grouped columns fold into synthetic ones and\r\n the group row becomes an ordinary row - so its subtotals line up under\r\n <strong>Deals</strong> and <strong>Revenue</strong> instead of sitting in a\r\n full-width strip.\r\n </p>\r\n\r\n <div class=\"mt-3 flex flex-wrap items-center gap-3\">\r\n <div class=\"inline-flex overflow-hidden rounded-md border\" style=\"border-color: var(--sg-border);\">\r\n {#each MODES as m (m.id)}\r\n <button\r\n type=\"button\" class=\"px-3 py-1 text-xs\"\r\n style={mode === m.id\r\n ? 'background: var(--sg-accent, #2563eb); color: var(--sg-on-accent, #fff);'\r\n : 'background: transparent; color: var(--sg-fg);'}\r\n onclick={() => (mode = m.id)}\r\n >{m.label}</button>\r\n {/each}\r\n </div>\r\n\r\n <label class=\"inline-flex items-center gap-1.5 text-xs\" style=\"color: var(--sg-fg);\">\r\n <input type=\"checkbox\" bind:checked={footers} />\r\n Group footers\r\n </label>\r\n\r\n <label class=\"inline-flex items-center gap-1.5 text-xs\" style=\"color: var(--sg-fg);\">\r\n Page size\r\n <select\r\n bind:value={pageSize}\r\n class=\"rounded border px-1.5 py-0.5 text-xs\"\r\n style=\"border-color: var(--sg-border); background: var(--sg-bg); color: var(--sg-fg);\"\r\n >\r\n <option value={5}>5</option>\r\n <option value={10}>10</option>\r\n <option value={25}>25</option>\r\n </select>\r\n </label>\r\n </div>\r\n\r\n <p class=\"mt-2 text-xs\" style=\"color: var(--sg-muted);\">{activeHint}</p>\r\n </div>\r\n\r\n <div class=\"flex-1 min-h-0\">\r\n <SvGrid\r\n responsive={true}\r\n data={rows}\r\n {columns}\r\n {features}\r\n groupable\r\n groupFooters={footers}\r\n groupDisplayMode={mode}\r\n pageable\r\n {pageSize}\r\n selectionMode=\"none\"\r\n enableRowSummaries={false}\r\n rowHeight={34}\r\n containerHeight=\"100%\"\r\n fitColumns={true}\r\n onApiReady={(a: SvGridApi<typeof features, Sale>) => {\r\n queueMicrotask(() => a.setGroupBy(['region', 'tier']))\r\n }}\r\n />\r\n </div>\r\n</section>\r\n"
2166
+ },
2167
+ {
2168
+ "id": "428-async-editor-options",
2169
+ "path": "examples/src/demos/428-async-editor-options.svelte",
2170
+ "title": "Async Editor Options",
2171
+ "blurb": "428. Async editor options `editorOptions` accepts an array, a `(row) => array` for cascades, and either of those returning a Promise:",
2172
+ "source": "<!-- Documented in: docs/help/editing/provided-editors.md -->\r\n<script lang=\"ts\">\r\n /**\r\n * 428. Async editor options\r\n * -------------------------\r\n * `editorOptions` accepts an array, a `(row) => array` for cascades, and\r\n * either of those returning a Promise:\r\n *\r\n * editorOptions: fetch('/api/users').then(r => r.json()) // per column\r\n * editorOptions: (row) => fetchCities(row.country) // per row\r\n *\r\n * While a request is in flight the dropdown shows \"Loading…\" rather than\r\n * \"No options\", which would read as \"nothing to pick\". Results are cached:\r\n * per column for a static source, and per row AND the row's data for a\r\n * cascade - so editing Country supersedes that row's city list on its own.\r\n * `api.refreshEditorOptions()` drops the cache when the server list changes.\r\n *\r\n * NOTE on the request counter below: the option source is invoked during\r\n * render, so it must not touch `$state` - Svelte forbids state writes inside a\r\n * derivation/template. The counter is bumped from inside the promise instead,\r\n * which lands after the render pass.\r\n */\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n type ColumnDef,\r\n type SvGridApi,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({ rowSortingFeature })\r\n\r\n type Ticket = {\r\n id: number\r\n title: string\r\n priority: string\r\n country: string\r\n city: string\r\n assignee: string\r\n }\r\n\r\n const rows: Ticket[] = [\r\n { id: 1, title: 'Invoice export fails on large ranges', priority: 'High', country: 'FR', city: 'Paris', assignee: '' },\r\n { id: 2, title: 'Dashboard first paint is slow', priority: 'Medium', country: 'JP', city: 'Osaka', assignee: '' },\r\n { id: 3, title: 'SSO redirect loops on refresh', priority: 'High', country: 'FR', city: 'Lyon', assignee: '' },\r\n { id: 4, title: 'Timezone off by one after DST', priority: 'Low', country: 'US', city: 'Austin', assignee: '' },\r\n { id: 5, title: 'CSV import drops trailing column', priority: 'Medium', country: 'US', city: 'Boston', assignee: '' },\r\n ]\r\n\r\n const CITIES: Record<string, string[]> = {\r\n FR: ['Paris', 'Lyon', 'Marseille', 'Toulouse', 'Bordeaux'],\r\n JP: ['Tokyo', 'Osaka', 'Kyoto', 'Nagoya', 'Sapporo'],\r\n US: ['Austin', 'Boston', 'Denver', 'Seattle', 'Chicago'],\r\n }\r\n\r\n const LATENCY = 700\r\n\r\n // Request log. Written from inside the promise (post-render), never from the\r\n // option source itself - see the note in the header comment.\r\n let log = $state<string[]>([])\r\n const requests = $derived(log.length)\r\n\r\n function fakeFetch<T>(label: string, value: T): Promise<T> {\r\n return new Promise((resolve) => {\r\n setTimeout(() => {\r\n log = [...log, label]\r\n resolve(value)\r\n }, LATENCY)\r\n })\r\n }\r\n\r\n // STATIC async source: one request for the whole column, cached after that.\r\n const assignees = fakeFetch('assignees (column)', [\r\n 'Ada Lovelace', 'Grace Hopper', 'Alan Turing', 'Margaret Hamilton', 'Linus Torvalds', 'Barbara Liskov',\r\n ])\r\n\r\n const columns: ColumnDef<typeof features, Ticket>[] = [\r\n { field: 'title', header: 'Ticket', width: 300 },\r\n {\r\n field: 'priority', header: 'Priority', width: 120,\r\n editorType: 'select', editorOptions: ['Low', 'Medium', 'High'],\r\n },\r\n {\r\n field: 'country', header: 'Country', width: 110,\r\n editorType: 'select', editorOptions: ['FR', 'JP', 'US'],\r\n },\r\n {\r\n field: 'city', header: 'City', width: 170,\r\n editorType: 'select',\r\n // PER-ROW async source: the list depends on the row's country, so it is\r\n // cached per row + row data - changing Country refetches just that row.\r\n editorOptions: (row: Ticket) => fakeFetch(`cities for ${row.country}`, CITIES[row.country] ?? []),\r\n },\r\n {\r\n field: 'assignee', header: 'Assignee', width: 200,\r\n editorType: 'rich-select', editorOptions: assignees,\r\n },\r\n ]\r\n\r\n let api = $state<SvGridApi<typeof features, Ticket> | null>(null)\r\n</script>\r\n\r\n<section class=\"flex flex-col flex-1 min-h-0 gap-3\">\r\n <div\r\n class=\"shrink-0 rounded-lg border px-4 py-3\"\r\n style=\"border-color: var(--sg-border); background: var(--sg-header-bg);\"\r\n >\r\n <p class=\"text-sm font-semibold\" style=\"color: var(--sg-fg);\">\r\n Option lists loaded from a server\r\n </p>\r\n <p class=\"mt-1 text-xs\" style=\"color: var(--sg-muted);\">\r\n Double-click a <strong>City</strong> or <strong>Assignee</strong> cell.\r\n Each list takes {LATENCY}ms to arrive and the dropdown shows\r\n <em>Loading…</em> until it does. Reopen the same cell - no second request.\r\n Change a row's <strong>Country</strong>, then reopen its City: that row\r\n refetches on its own, because a cascade is cached per row AND per the\r\n row's data - other rows keep their cached lists.\r\n </p>\r\n\r\n <div class=\"mt-3 flex flex-wrap items-center gap-3\">\r\n <span\r\n class=\"rounded-md border px-2 py-1 text-xs\"\r\n style=\"border-color: var(--sg-border); color: var(--sg-fg);\"\r\n >\r\n Requests: <strong>{requests}</strong>\r\n </span>\r\n <button\r\n type=\"button\" class=\"rounded-md border px-3 py-1 text-xs\"\r\n style=\"border-color: var(--sg-border); color: var(--sg-fg);\"\r\n onclick={() => api?.refreshEditorOptions()}\r\n >Invalidate cache</button>\r\n <button\r\n type=\"button\" class=\"rounded-md border px-3 py-1 text-xs\"\r\n style=\"border-color: var(--sg-border); color: var(--sg-fg);\"\r\n onclick={() => (log = [])}\r\n >Clear log</button>\r\n <span class=\"text-xs\" style=\"color: var(--sg-muted);\">\r\n A rejected request settles on \"No options\" rather than spinning.\r\n </span>\r\n </div>\r\n\r\n {#if log.length}\r\n <ul class=\"mt-2 flex flex-wrap gap-1.5\">\r\n {#each log.slice(-6) as entry, i (i)}\r\n <li\r\n class=\"rounded px-1.5 py-0.5 text-[11px]\"\r\n style=\"background: var(--sg-bg); border: 1px solid var(--sg-border); color: var(--sg-muted);\"\r\n >{entry}</li>\r\n {/each}\r\n </ul>\r\n {/if}\r\n </div>\r\n\r\n <div class=\"flex-1 min-h-0\">\r\n <SvGrid\r\n responsive={true}\r\n data={rows}\r\n {columns}\r\n {features}\r\n editable\r\n selectionMode=\"none\"\r\n enableRowSummaries={false}\r\n rowHeight={34}\r\n containerHeight=\"100%\"\r\n fitColumns={true}\r\n onApiReady={(a) => (api = a)}\r\n />\r\n </div>\r\n</section>\r\n"
2173
+ },
2153
2174
  {
2154
2175
  "id": "43-compliance-queue",
2155
2176
  "path": "examples/src/demos/43-compliance-queue.svelte",
@@ -2651,13 +2672,13 @@ export const docs = [
2651
2672
  "slug": "enterprise/evaluation",
2652
2673
  "path": "docs/enterprise/evaluation.md",
2653
2674
  "title": "Enterprise evaluation",
2654
- "markdown": "# Enterprise evaluation\r\n\r\nThe `@svgrid/enterprise` package is soft-gated; you can evaluate every\r\nfeature in production-equivalent code paths without contacting\r\nsales. This page is the playbook.\r\n\r\n![Soft-gated evaluation: install, try every feature while a watermark shows, then set a license key when ready, with no gated-off code paths.](/docs-media/enterprise-evaluation.svg)\r\n\r\n## Step 1: Install\r\n\r\n```bash\r\npnpm add @svgrid/enterprise\r\n# Add the peers for the features you want to evaluate:\r\npnpm add jszip # xlsx export/import\r\npnpm add pdfmake # pdf export\r\n```\r\n\r\n`jszip` and `pdfmake` are lazy-loaded by @svgrid/enterprise - they're only\r\nrequired if you actually invoke the matching feature.\r\n\r\n## Step 2: Install the evaluation key\r\n\r\n```ts\r\nimport { setLicenseKey } from '@svgrid/enterprise'\r\nsetLicenseKey('SVENTERPRISE-DEV-DEMO')\r\n```\r\n\r\nThis suppresses the watermark in local dev. **For staging /\r\nproduction evaluation, request an evaluation key** at\r\n[svgrid.com/evaluate](https://svgrid.com/evaluate) - it's a one-form\r\nrequest, no sales call, key arrives in ~10 minutes.\r\n\r\nThe evaluation key is a real key with a 30-day expiry. Behaves\r\nidentically to a paid license; lets you ship internal staging /\r\ndemo deployments to evaluators without the watermark.\r\n\r\n### What unlicensed looks like\r\n\r\nWith no key set, Enterprise stays fully functional but nudges you:\r\n\r\n- A small **\"www.svgrid.com\" watermark** in the corner of each grid\r\n (fades after 5 seconds).\r\n- The first time you actually invoke a Enterprise feature (export, import,\r\n print, AI), a one-time **upgrade card** appears in the bottom-right\r\n naming that feature, with a one-click link to start a free trial. It\r\n shows at most once per session.\r\n\r\nBoth are pure DOM - **no network calls, no cookies, no web storage**\r\n(see [security](../help/security.md)). `setLicenseKey()` with any\r\nvalid key suppresses them before they appear. To remove the upgrade\r\ncard programmatically (e.g. you render your own upgrade UI), call:\r\n\r\n```ts\r\nimport { dismissUpgradePrompt } from '@svgrid/enterprise'\r\ndismissUpgradePrompt()\r\n```\r\n\r\n## Step 3: Wire up\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, type SvGridApi } from '@svgrid/grid'\r\n import { installEnterprise, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n</script>\r\n\r\n<SvGrid\r\n data={rows} columns={columns} features={features}\r\n onApiReady={(next: SvGridApi<typeof features, Order>) => {\r\n api = installEnterprise(next)\r\n }}\r\n/>\r\n\r\n<button onclick={() => api?.exportData({ format: 'xlsx' })}>Export</button>\r\n```\r\n\r\nThat's the integration. The Community grid is unchanged; Enterprise\r\naugments the api object.\r\n\r\n## Step 4: Try the features (30-min tour)\r\n\r\n| Feature | One-line evaluation |\r\n| ------- | -------------------------------------------------------------------- |\r\n| Export | `api.exportData({ format: 'xlsx', filename: 'data' })` |\r\n| Pivot | `const pivot = createPivotModel(rows, { rows: ['region'], cols: ['quarter'], values: [{ field: 'amount', agg: 'sum' }] })` |\r\n| Import | `<input type=\"file\" onchange={(e) => api.importData({ file: e.target.files[0] }).then(r => api.addRows(r.rows))}>` |\r\n| AI | `setAIProvider(yourAdapter); const plan = await api.ai.filter('show last quarter > $10k')` |\r\n\r\nEach Enterprise feature has a fully-working demo in the gallery\r\n([56-60 + 51 + 52 + 53](https://svgrid.com/demos/)) that you can\r\nread end-to-end.\r\n\r\n## Step 5: Performance + budget check\r\n\r\nBundle sizes (gzip):\r\n\r\n| Surface | Size | Notes |\r\n| --------------- | ----- | ---------------------------------- |\r\n| Community only | 80 kB | Renderer + engine (+ 9 kB CSS) |\r\n| + Enterprise export | +12 kB| + `jszip` peer when xlsx is used |\r\n| + Enterprise pdf | +90 kB| + `pdfmake` peer when pdf is used |\r\n| + Enterprise pivot | +6 kB | Pure TS, no peers |\r\n| + Enterprise import | +5 kB | + `jszip` for xlsx import |\r\n\r\nSubpath imports (`@svgrid/enterprise/export`, `@svgrid/enterprise/pivot`, etc.)\r\nensure you only pay for what you use.\r\n\r\n## Step 6: Decide\r\n\r\n- Shipping one production app? **Single Application Developer License**\r\n ($599 per developer).\r\n- Shipping multiple apps across your org? **Multiple Application\r\n Developer License** ($999 per developer).\r\n- Large team (5+), multi-year, NDA, or PO? **Enterprise / volume**\r\n (contact sales).\r\n\r\nEach is a perpetual license + 1 year of updates and support that renews\r\nautomatically; cancel anytime.\r\n\r\n[Full pricing](https://svgrid.com/pricing/).\r\n\r\n## Migrating from another grid mid-evaluation\r\n\r\nIf you're swapping out an existing grid, see the\r\n[migration guides](../help/migrating-from-ag-grid.md) - typically a half-day\r\nport for a 5-grid app.\r\n\r\n## See also\r\n\r\n- [Enterprise licensing](./licensing.md) - what each tier covers\r\n- [Enterprise support](./support.md) - what you get with each tier\r\n- [Missing features](../help/missing-features.md) - the honest gap list\r\n"
2675
+ "markdown": "# Enterprise evaluation\r\n\r\nThe `@svgrid/enterprise` package is soft-gated; you can evaluate every\r\nfeature in production-equivalent code paths without contacting\r\nsales. This page is the playbook.\r\n\r\n![Soft-gated evaluation: install, try every feature while a watermark shows, then set a license key when ready, with no gated-off code paths.](/docs-media/enterprise-evaluation.svg)\r\n\r\n## Step 1: Install\r\n\r\n```bash\r\npnpm add @svgrid/enterprise\r\n# Add the peers for the features you want to evaluate:\r\npnpm add jszip # xlsx export/import\r\npnpm add pdfmake # pdf export\r\n```\r\n\r\n`jszip` and `pdfmake` are lazy-loaded by @svgrid/enterprise - they're only\r\nrequired if you actually invoke the matching feature.\r\n\r\n## Step 2: Install the evaluation key\r\n\r\n```ts\r\nimport { setLicenseKey } from '@svgrid/enterprise'\r\nsetLicenseKey('SVENTERPRISE-DEV-DEMO')\r\n```\r\n\r\nThis suppresses the watermark in local dev. **For staging /\r\nproduction evaluation, request an evaluation key** at\r\n[svgrid.com/contact](https://svgrid.com/contact/) - no sales call\r\nrequired.\r\n\r\nThe evaluation key is a real key with a 30-day expiry. Behaves\r\nidentically to a paid license; lets you ship internal staging /\r\ndemo deployments to evaluators without the watermark.\r\n\r\n### What unlicensed looks like\r\n\r\nWith no key set, Enterprise stays fully functional but nudges you:\r\n\r\n- A small **\"www.svgrid.com\" watermark** in the corner of each grid\r\n (fades after 5 seconds).\r\n- The first time you actually invoke a Enterprise feature (export, import,\r\n print, AI), a one-time **upgrade card** appears in the bottom-right\r\n naming that feature, with a one-click link to start a free trial. It\r\n shows at most once per session.\r\n\r\nBoth are pure DOM - **no network calls, no cookies, no web storage**\r\n(see [security](../help/security.md)). `setLicenseKey()` with any\r\nvalid key suppresses them before they appear. To remove the upgrade\r\ncard programmatically (e.g. you render your own upgrade UI), call:\r\n\r\n```ts\r\nimport { dismissUpgradePrompt } from '@svgrid/enterprise'\r\ndismissUpgradePrompt()\r\n```\r\n\r\n## Step 3: Wire up\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, type SvGridApi } from '@svgrid/grid'\r\n import { installEnterprise, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n</script>\r\n\r\n<SvGrid\r\n data={rows} columns={columns} features={features}\r\n onApiReady={(next: SvGridApi<typeof features, Order>) => {\r\n api = installEnterprise(next)\r\n }}\r\n/>\r\n\r\n<button onclick={() => api?.exportData({ format: 'xlsx' })}>Export</button>\r\n```\r\n\r\nThat's the integration. The Community grid is unchanged; Enterprise\r\naugments the api object.\r\n\r\n## Step 4: Try the features (30-min tour)\r\n\r\n| Feature | One-line evaluation |\r\n| ------- | -------------------------------------------------------------------- |\r\n| Export | `api.exportData({ format: 'xlsx', filename: 'data' })` |\r\n| Pivot | `const pivot = createPivotModel(rows, { rows: ['region'], cols: ['quarter'], values: [{ field: 'amount', agg: 'sum' }] })` |\r\n| Import | `<input type=\"file\" onchange={(e) => api.importData({ file: e.target.files[0] }).then(r => api.addRows(r.rows))}>` |\r\n| AI | `setAIProvider(yourAdapter); const plan = await api.ai.filter('show last quarter > $10k')` |\r\n\r\nEach Enterprise feature has a fully-working demo in the gallery\r\n([56-60 + 51 + 52 + 53](https://svgrid.com/demos/)) that you can\r\nread end-to-end.\r\n\r\n## Step 5: Performance + budget check\r\n\r\nBundle sizes (gzip):\r\n\r\n| Surface | Size | Notes |\r\n| --------------- | ----- | ---------------------------------- |\r\n| Community only | 80 kB | Renderer + engine (+ 9 kB CSS) |\r\n| + Enterprise export | +12 kB| + `jszip` peer when xlsx is used |\r\n| + Enterprise pdf | +90 kB| + `pdfmake` peer when pdf is used |\r\n| + Enterprise pivot | +6 kB | Pure TS, no peers |\r\n| + Enterprise import | +5 kB | + `jszip` for xlsx import |\r\n\r\nSubpath imports (`@svgrid/enterprise/export`, `@svgrid/enterprise/pivot`, etc.)\r\nensure you only pay for what you use.\r\n\r\n## Step 6: Decide\r\n\r\n- Shipping one production app? **Single Application Developer License**\r\n ($599 per developer).\r\n- Shipping multiple apps across your org? **Multiple Application\r\n Developer License** ($999 per developer).\r\n- Large team (5+), multi-year, NDA, or PO? **Enterprise / volume**\r\n (contact sales).\r\n\r\nEach is a perpetual license + 1 year of updates and support that renews\r\nautomatically; cancel anytime.\r\n\r\n[Full pricing](https://svgrid.com/pricing/).\r\n\r\n## Migrating from another grid mid-evaluation\r\n\r\nIf you're swapping out an existing grid, see the\r\n[migration guides](../help/migrating-from-ag-grid.md) - typically a half-day\r\nport for a 5-grid app.\r\n\r\n## See also\r\n\r\n- [Enterprise licensing](./licensing.md) - what each tier covers\r\n- [Enterprise support](./support.md) - what you get with each tier\r\n- [Missing features](../help/missing-features.md) - the honest gap list\r\n"
2655
2676
  },
2656
2677
  {
2657
2678
  "slug": "enterprise/licensing",
2658
2679
  "path": "docs/enterprise/licensing.md",
2659
2680
  "title": "Enterprise licensing",
2660
- "markdown": "# Enterprise licensing\r\n\r\nThe `@svgrid/enterprise` package is **soft-gated**: every feature runs\r\nwithout a key, with a small unlicensed-build watermark in the\r\nbottom-right corner of the grid + a one-time console nudge. Set a\r\nkey once at app startup and both disappear.\r\n\r\n![The soft-gate model: without a key the grid runs fully with a small watermark and a one-time console nudge; calling setLicenseKey once clears both, and every feature runs either way.](/docs-media/enterprise-licensing.svg)\r\n\r\n## Setting the key\r\n\r\n```ts\r\nimport { setLicenseKey } from '@svgrid/enterprise'\r\n\r\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\r\n```\r\n\r\nCall this before the first `<SvGrid>` mounts. In Vite/SvelteKit,\r\nexpose the key as `VITE_SVPRO_KEY=SVENTERPRISE-...` in your `.env` (NOT\r\nchecked in) - or read it from your config service.\r\n\r\n## License tiers\r\n\r\nEvery Enterprise license is a **perpetual license** that includes **1 year of\r\nupdates and support**. The price shown **renews automatically each year**\r\nto keep updates and support active; **cancel anytime** and you keep every\r\nversion released during your paid term.\r\n\r\n| License | Apps covered | Price (per developer) | Support |\r\n| ------------------------------------------ | ----------------------------------- | --------------------- | ----------------------------------------- |\r\n| **Single Application Developer License** | One deployed production app | **$599** | Email (next business day) + private Slack |\r\n| **Multiple Application Developer License** | Unlimited apps in your organisation | **$999** | Email (next business day) + private Slack |\r\n| **Enterprise / volume** | Unlimited | Custom quote | Priority + named contact, NDA / PO |\r\n\r\n> Priced **per developer** - engineers who write or modify code that imports\r\n> `@svgrid/enterprise`. Production seats / end users are unlimited. One license key\r\n> activates every grid in scope - no per-page or per-component accounting.\r\n> Teams of 5+ and multi-year terms get volume discounts; email\r\n> `sales@jqwidgets.com`.\r\n\r\n## License key format\r\n\r\n```\r\nSVENTERPRISE-IXIX-XXXX-XXXX-XXXX-XXXX\r\n │\r\n └── single hex-encoded payload, verified locally\r\n```\r\n\r\nThe check is purely client-side, signed against our public key.\r\n**No network call** is ever made to validate. Air-gapped deployments\r\nwork out of the box.\r\n\r\n## Per-environment keys\r\n\r\nDifferent keys for different deployment stages so revoking is surgical:\r\n\r\n```ts\r\nconst KEY = {\r\n development: import.meta.env.VITE_SVPRO_DEV_KEY,\r\n staging: import.meta.env.VITE_SVPRO_STAGING_KEY,\r\n production: import.meta.env.VITE_SVPRO_PROD_KEY,\r\n}[import.meta.env.MODE] ?? ''\r\n\r\nsetLicenseKey(KEY)\r\n```\r\n\r\n## Dev / demo key\r\n\r\nFor demos + integration tests, use the published sentinel:\r\n\r\n```ts\r\nsetLicenseKey('SVENTERPRISE-DEV-DEMO')\r\n```\r\n\r\nThis suppresses the watermark for local development. **Do not ship\r\nthis key to production** - it's a dev-only convenience and will be\r\nrevoked in any production-domain validation pipeline.\r\n\r\n## What happens without a key\r\n\r\n| Surface | Behaviour |\r\n| --------------------------- | ---------------------------------------------------------- |\r\n| Grid render | Works. |\r\n| Editing, sort, filter | Works. |\r\n| `pro.exportData(...)` | Works. |\r\n| `pro.importData(...)` | Works. |\r\n| `pro.ai.*` | Works. |\r\n| `createPivotModel(...)` | Works. |\r\n| Studio: designer / panels | Works. |\r\n| Studio: `createSqlDataSource` | Works. |\r\n| Studio: MCP generator | Works; output carries a one-line notice. |\r\n| Unlicensed watermark | Visible on every grid instance. |\r\n| Console nudge | Logged once per page load. |\r\n\r\nNothing is \"trial mode\" - the soft-gate is meant for evaluation. Once\r\nyou're sold, drop in a key.\r\n\r\n## Studio (data-app generator)\r\n\r\nThe Studio - the schema designer, edit panel, master-detail, SQL data\r\nsource, and the AI generator - is part of the **same** Enterprise\r\nlicense. One key covers everything; there's no separate Studio tier or\r\nper-feature entitlement. It's **soft-gate only**: every Studio surface\r\nruns unlicensed, it just nudges.\r\n\r\nIt has two places you set the key, because it runs in two places:\r\n\r\n**1. In your app (the browser)** - the same `setLicenseKey()` you\r\nalready call. The designer, edit panel, master-detail, and data sources\r\nall read it. Nothing new to do.\r\n\r\n```ts\r\nimport { setLicenseKey } from '@svgrid/enterprise'\r\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\r\n```\r\n\r\n**2. In the AI generator (the MCP server)** - the generator runs in a\r\nNode process (Claude Code / Desktop), so it reads the key from the\r\n`SVGRID_LICENSE_KEY` environment variable in your MCP config. Set it\r\nonce:\r\n\r\n```jsonc\r\n{\r\n \"mcpServers\": {\r\n \"svgrid\": {\r\n \"command\": \"npx\",\r\n \"args\": [\"@svgrid/mcp\"],\r\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\r\n }\r\n }\r\n}\r\n```\r\n\r\nWithout it, the generator still produces code - it just prepends a\r\none-line commercial notice, and the generated app carries the usual\r\nwatermark until you call `setLicenseKey()` in it. Same honor-system,\r\nsame key format, no network calls.\r\n\r\n## License renewal\r\n\r\nThe license itself is perpetual; what renews each year is your\r\n**updates-and-support term**. It renews automatically until you cancel\r\n(cancel anytime). The key keeps validating every version released during\r\na paid term, and if you cancel or let the term lapse, your installed\r\nversion keeps working - you just stop receiving new releases and support.\r\n\r\n- 60 days before the term ends: yellow nudge on the watermark\r\n- 30 days: orange\r\n- 7 days: red\r\n- After the term ends (if you cancelled): features keep working; only\r\n versions released after your paid term need a renewed term\r\n\r\nWe never disable working features on a paying customer.\r\n\r\n## Audit trail\r\n\r\nEach license key has a stable ID embedded. The same key activates as\r\nmany builds as you want; the ID is what we cross-reference against\r\nyour subscription in support tickets. **No telemetry is sent** -\r\nsupport uses the ID you give us, not anything we phoned home for.\r\n\r\n## See also\r\n\r\n- [Enterprise evaluation](./evaluation.md) - 30-day evaluation flow\r\n- [Enterprise support](./support.md) - SLAs, escalation, contact channels\r\n- [Pricing](https://svgrid.com/pricing/) - canonical pricing source\r\n"
2681
+ "markdown": "# Enterprise licensing\r\n\r\nThe `@svgrid/enterprise` package is **soft-gated**: every feature runs\r\nwithout a key, with a small unlicensed-build watermark in the\r\nbottom-right corner of the grid + a one-time console nudge. Set a\r\nkey once at app startup and both disappear.\r\n\r\n![The soft-gate model: without a key the grid runs fully with a small watermark and a one-time console nudge; calling setLicenseKey once clears both, and every feature runs either way.](/docs-media/enterprise-licensing.svg)\r\n\r\n## Setting the key\r\n\r\n```ts\r\nimport { setLicenseKey } from '@svgrid/enterprise'\r\n\r\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\r\n```\r\n\r\nCall this before the first `<SvGrid>` mounts. In Vite/SvelteKit,\r\nexpose the key as `VITE_SVPRO_KEY=SVENTERPRISE-...` in your `.env` (NOT\r\nchecked in) - or read it from your config service.\r\n\r\n## License tiers\r\n\r\nEvery Enterprise license is a **perpetual license** that includes **1 year of\r\nupdates and support**. The price shown **renews automatically each year**\r\nto keep updates and support active; **cancel anytime** and you keep every\r\nversion released during your paid term.\r\n\r\n| License | Apps covered | Price (per developer) | Support |\r\n| ------------------------------------------ | ----------------------------------- | --------------------- | ----------------------------------------- |\r\n| **Single Application Developer License** | One deployed production app | **$599** | Email (next business day) + private Slack |\r\n| **Multiple Application Developer License** | Unlimited apps in your organisation | **$999** | Email (next business day) + private Slack |\r\n| **Enterprise / volume** | Unlimited | Custom quote | Priority + named contact, NDA / PO |\r\n\r\n> Priced **per developer** - engineers who write or modify code that imports\r\n> `@svgrid/enterprise`. Production seats / end users are unlimited. One license key\r\n> activates every grid in scope - no per-page or per-component accounting.\r\n> Teams of 5+ and multi-year terms get volume discounts; email\r\n> `sales@jqwidgets.com`.\r\n\r\n## License key format\r\n\r\n```\r\nSVENTERPRISE-IXIX-XXXX-XXXX-XXXX-XXXX\r\n│\r\n└── prefix the runtime recognises; the rest identifies your license\r\n```\r\n\r\nThe check is purely client-side and **no network call** is ever made to\r\nvalidate, so air-gapped deployments work out of the box.\r\n\r\nIt is also deliberately not cryptography. The runtime classifies the key\r\nstring - prefix recognised, on the revoked list, a `DEV` / `EVAL` sentinel, or\r\na paid key - and nothing more (`checkLicenseKey` in\r\n`packages/enterprise/src/license-core.ts`). Anyone with devtools can read a key\r\nout of a deployed bundle, and an unlicensed build still runs; it just shows a\r\nwatermark and logs a one-time notice. The license is a legal agreement, not a\r\ntechnical lock, and we would rather say so than imply a DRM scheme that isn't\r\nthere. Keys are revocable: a key we revoke stops being accepted in later\r\nreleases.\r\n\r\n## Per-environment keys\r\n\r\nDifferent keys for different deployment stages so revoking is surgical:\r\n\r\n```ts\r\nconst KEY = {\r\n development: import.meta.env.VITE_SVPRO_DEV_KEY,\r\n staging: import.meta.env.VITE_SVPRO_STAGING_KEY,\r\n production: import.meta.env.VITE_SVPRO_PROD_KEY,\r\n}[import.meta.env.MODE] ?? ''\r\n\r\nsetLicenseKey(KEY)\r\n```\r\n\r\n## Dev / demo key\r\n\r\nFor demos + integration tests, use the published sentinel:\r\n\r\n```ts\r\nsetLicenseKey('SVENTERPRISE-DEV-DEMO')\r\n```\r\n\r\nThis suppresses the watermark for local development. **Do not ship\r\nthis key to production** - it's a dev-only convenience and will be\r\nrevoked in any production-domain validation pipeline.\r\n\r\n## What happens without a key\r\n\r\n| Surface | Behaviour |\r\n| --------------------------- | ---------------------------------------------------------- |\r\n| Grid render | Works. |\r\n| Editing, sort, filter | Works. |\r\n| `pro.exportData(...)` | Works. |\r\n| `pro.importData(...)` | Works. |\r\n| `pro.ai.*` | Works. |\r\n| `createPivotModel(...)` | Works. |\r\n| Studio: designer / panels | Works. |\r\n| Studio: `createSqlDataSource` | Works. |\r\n| Studio: MCP generator | Works; output carries a one-line notice. |\r\n| Unlicensed watermark | Visible on every grid instance. |\r\n| Console nudge | Logged once per page load. |\r\n\r\nNothing is \"trial mode\" - the soft-gate is meant for evaluation. Once\r\nyou're sold, drop in a key.\r\n\r\n## Studio (data-app generator)\r\n\r\nThe Studio - the schema designer, edit panel, master-detail, SQL data\r\nsource, and the AI generator - is part of the **same** Enterprise\r\nlicense. One key covers everything; there's no separate Studio tier or\r\nper-feature entitlement. It's **soft-gate only**: every Studio surface\r\nruns unlicensed, it just nudges.\r\n\r\nIt has two places you set the key, because it runs in two places:\r\n\r\n**1. In your app (the browser)** - the same `setLicenseKey()` you\r\nalready call. The designer, edit panel, master-detail, and data sources\r\nall read it. Nothing new to do.\r\n\r\n```ts\r\nimport { setLicenseKey } from '@svgrid/enterprise'\r\nsetLicenseKey(import.meta.env.VITE_SVPRO_KEY)\r\n```\r\n\r\n**2. In the AI generator (the MCP server)** - the generator runs in a\r\nNode process (Claude Code / Desktop), so it reads the key from the\r\n`SVGRID_LICENSE_KEY` environment variable in your MCP config. Set it\r\nonce:\r\n\r\n```jsonc\r\n{\r\n \"mcpServers\": {\r\n \"svgrid\": {\r\n \"command\": \"npx\",\r\n \"args\": [\"@svgrid/mcp\"],\r\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\r\n }\r\n }\r\n}\r\n```\r\n\r\nWithout it, the generator still produces code - it just prepends a\r\none-line commercial notice, and the generated app carries the usual\r\nwatermark until you call `setLicenseKey()` in it. Same honor-system,\r\nsame key format, no network calls.\r\n\r\n## License renewal\r\n\r\nThe license itself is perpetual; what renews each year is your\r\n**updates-and-support term**. It renews automatically until you cancel\r\n(cancel anytime). The key keeps validating every version released during\r\na paid term, and if you cancel or let the term lapse, your installed\r\nversion keeps working - you just stop receiving new releases and support.\r\n\r\n- 60 days before the term ends: yellow nudge on the watermark\r\n- 30 days: orange\r\n- 7 days: red\r\n- After the term ends (if you cancelled): features keep working; only\r\n versions released after your paid term need a renewed term\r\n\r\nWe never disable working features on a paying customer.\r\n\r\n## Audit trail\r\n\r\nEach license key has a stable ID embedded. The same key activates as\r\nmany builds as you want; the ID is what we cross-reference against\r\nyour subscription in support tickets. **No telemetry is sent** -\r\nsupport uses the ID you give us, not anything we phoned home for.\r\n\r\n## See also\r\n\r\n- [Enterprise evaluation](./evaluation.md) - 30-day evaluation flow\r\n- [Enterprise support](./support.md) - SLAs, escalation, contact channels\r\n- [Pricing](https://svgrid.com/pricing/) - canonical pricing source\r\n"
2661
2682
  },
2662
2683
  {
2663
2684
  "slug": "enterprise/studio",
@@ -2669,7 +2690,7 @@ export const docs = [
2669
2690
  "slug": "enterprise/studio/access-control",
2670
2691
  "path": "docs/enterprise/studio/access-control.md",
2671
2692
  "title": "Access control (RBAC)",
2672
- "markdown": "# Access control (RBAC)\n\nStudio has **authentication** (who you are, via [`SvAuthGate`](./auth.md) +\nSupabase) and, separately, **authorization** - what a signed-in user may do.\nRole-based access control (RBAC) gates **screens** and **write actions**\n(create / update / delete) per role, and it is enforced in **two** places: the\ngenerated UI *and* the generated API route. Server enforcement is the point - a\ntampered client can hide the buttons all it likes; the route still rejects the\nwrite.\n\n![RBAC gates screens and write actions per role, enforced in the generated UI and again in the generated API route, which rejects a tampered client's write.](/docs-media/studio-rbac.svg)\n\n> Reads are implied by screen access: if a role can open a screen, it can read\n> that entity. The three gated actions are **create**, **update**, **delete**.\n\n## Turn it on\n\nIn the [visual designer](./app-designer.md), open the inspector with no block\nselected and expand **Access control (app-wide)**. Tick **Enable role-based\naccess** and you get three starter roles - `admin`, `editor`, `viewer` - which\nyou can rename, remove, or add to. For each role, choose:\n\n- **Actions** - create / update / delete checkboxes (all three ticked = full write).\n- **Screens** - *All screens*, or a specific subset.\n\nPick a **Default role** - the fallback when the app can't resolve one from the\nsession (it default-denies writes, so make it your least-privileged role).\n\nThe same policy lives in the project model, so it round-trips through\n`studio.config.json`:\n\n```ts\nimport type { StudioProject } from '@svgrid/enterprise'\n\nconst access: StudioProject['access'] = {\n enabled: true,\n defaultRole: 'viewer',\n roles: [\n { role: 'admin', screens: '*', actions: '*' },\n { role: 'editor', screens: '*', actions: ['create', 'update', 'delete'] },\n { role: 'viewer', screens: ['customers'], actions: [] },\n ],\n}\n```\n\n## What gets generated\n\nWith RBAC on, **Generate app** emits `src/lib/access.ts` - the single policy\nmodule shared by every screen and route:\n\n```ts\nimport { writable } from 'svelte/store'\n\nexport type AppRole = 'admin' | 'editor' | 'viewer'\nexport const currentRole = writable<AppRole>('viewer') // set this after login\n\nexport function canScreen(role: AppRole, screenId: string): boolean { /* ... */ }\nexport function can(role: AppRole, action: 'create' | 'update' | 'delete'): boolean { /* ... */ }\n\n// server-side\nexport function getServerRole(event: { locals?: Record<string, unknown> }): AppRole { /* reads event.locals.role */ }\nexport function authorizeAction(role: AppRole, action: 'read' | 'create' | 'update' | 'delete'): boolean { /* ... */ }\n```\n\n- The **layout** hides nav links a role can't open (`canScreen($currentRole, id)`).\n- Each **screen** hides the **+ New** button without `create` and blocks the edit\n form / inline edits without `update`.\n- Each SQL / Supabase **API route** passes an `authorize` hook to\n `createKitHandlers`, so the server rejects unauthorized writes with `403`.\n\n## Wire the role\n\nTwo ends to connect, both one-liners:\n\n**Client** - set `currentRole` once you know the user (after `SvAuthGate` signs\nthem in, or from `+layout`'s data):\n\n```svelte\n<script lang=\"ts\">\n import { currentRole } from '$lib/access'\n import { onMount } from 'svelte'\n onMount(() => currentRole.set(mySession.role)) // 'admin' | 'editor' | 'viewer'\n</script>\n```\n\n**Server** - put the role on `event.locals` in `hooks.server.ts` so\n`getServerRole` finds it (this is what actually enforces access):\n\n```ts\n// src/hooks.server.ts\nexport const handle = async ({ event, resolve }) => {\n const session = await getSession(event) // your auth\n event.locals.role = session?.role ?? 'viewer'\n return resolve(event)\n}\n```\n\n## The `authorize` hook (hand-written apps too)\n\nRBAC is built on a hook you can use without Studio. `createKitHandlers` accepts\nan `authorize` callback run before every op; return `false` (or throw) to reject:\n\n```ts\nimport { createKitHandlers } from '@svgrid/enterprise'\n\nexport const { POST } = createKitHandlers({\n schema: customersSchema,\n source,\n authorize: ({ action, event }) => {\n const role = event.locals?.role\n if (action === 'read') return true\n return role === 'admin' || role === 'editor'\n },\n})\n```\n\n`action` is `'read' | 'create' | 'update' | 'delete'`, and `event` carries the\nSvelteKit `locals` so you can read the session. It runs **before** the data\nsource is touched, so an unauthorized request never reaches your database.\n\n## Layering with Row-Level Security\n\nRBAC decides *which actions* a role may perform. To also scope *which rows* a\nuser sees, combine it with database [Row-Level Security](./auth.md#scope-data-per-user-with-rls) -\nRBAC in the app for actions + screens, RLS in Postgres for row visibility. The\ntwo are complementary: keep both on for defense in depth.\n\n## See also\n\n- [Auth & secured screens](./auth.md) - authentication + RLS\n- [Code generation](./code-generation.md) Ā· [Databases](./databases.md) - the API routes RBAC guards\n- [The visual designer](./app-designer.md) - where you author the policy\n"
2693
+ "markdown": "# Access control (RBAC)\r\n\r\nStudio has **authentication** (who you are, via [`SvAuthGate`](./auth.md) +\r\nSupabase) and, separately, **authorization** - what a signed-in user may do.\r\nRole-based access control (RBAC) gates **screens** and **write actions**\r\n(create / update / delete) per role, and it is enforced in **two** places: the\r\ngenerated UI *and* the generated API route. Server enforcement is the point - a\r\ntampered client can hide the buttons all it likes; the route still rejects the\r\nwrite.\r\n\r\n![RBAC gates screens and write actions per role, enforced in the generated UI and again in the generated API route, which rejects a tampered client's write.](/docs-media/studio-rbac.svg)\r\n\r\n> Reads are implied by screen access: if a role can open a screen, it can read\r\n> that entity. The three gated actions are **create**, **update**, **delete**.\r\n\r\n## Turn it on\r\n\r\nIn the [visual designer](./app-designer.md), open the inspector with no block\r\nselected and expand **Access control (app-wide)**. Tick **Enable role-based\r\naccess** and you get three starter roles - `admin`, `editor`, `viewer` - which\r\nyou can rename, remove, or add to. For each role, choose:\r\n\r\n- **Actions** - create / update / delete checkboxes (all three ticked = full write).\r\n- **Screens** - *All screens*, or a specific subset.\r\n\r\nPick a **Default role** - the fallback when the app can't resolve one from the\r\nsession (it default-denies writes, so make it your least-privileged role).\r\n\r\nThe same policy lives in the project model, so it round-trips through\r\n`studio.config.json`:\r\n\r\n```ts\r\nimport type { StudioProject } from '@svgrid/enterprise'\r\n\r\nconst access: StudioProject['access'] = {\r\n enabled: true,\r\n defaultRole: 'viewer',\r\n roles: [\r\n { role: 'admin', screens: '*', actions: '*' },\r\n { role: 'editor', screens: '*', actions: ['create', 'update', 'delete'] },\r\n { role: 'viewer', screens: ['customers'], actions: [] },\r\n ],\r\n}\r\n```\r\n\r\n## What gets generated\r\n\r\nWith RBAC on, **Generate app** emits `src/lib/access.ts` - the single policy\r\nmodule shared by every screen and route:\r\n\r\n```ts\r\nimport { writable } from 'svelte/store'\r\n\r\nexport type AppRole = 'admin' | 'editor' | 'viewer'\r\nexport const currentRole = writable<AppRole>('viewer') // set this after login\r\n\r\nexport function canScreen(role: AppRole, screenId: string): boolean { /* ... */ }\r\nexport function can(role: AppRole, action: 'create' | 'update' | 'delete'): boolean { /* ... */ }\r\n\r\n// server-side\r\nexport function getServerRole(event: { locals?: Record<string, unknown> }): AppRole { /* reads event.locals.role */ }\r\nexport function authorizeAction(role: AppRole, action: 'read' | 'create' | 'update' | 'delete'): boolean { /* ... */ }\r\n```\r\n\r\n- The **layout** hides nav links a role can't open (`canScreen($currentRole, id)`).\r\n- Each **screen** hides the **+ New** button without `create` and blocks the edit\r\n form / inline edits without `update`.\r\n- Each SQL / Supabase **API route** passes an `authorize` hook to\r\n `createKitHandlers`, so the server rejects unauthorized writes with `403`.\r\n\r\n## Wire the role\r\n\r\nTwo ends to connect, both one-liners:\r\n\r\n**Client** - set `currentRole` once you know the user (after `SvAuthGate` signs\r\nthem in, or from `+layout`'s data):\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { currentRole } from '$lib/access'\r\n import { onMount } from 'svelte'\r\n onMount(() => currentRole.set(mySession.role)) // 'admin' | 'editor' | 'viewer'\r\n</script>\r\n```\r\n\r\n**Server** - put the role on `event.locals` in `hooks.server.ts` so\r\n`getServerRole` finds it (this is what actually enforces access):\r\n\r\n```ts\r\n// src/hooks.server.ts\r\nexport const handle = async ({ event, resolve }) => {\r\n const session = await getSession(event) // your auth\r\n event.locals.role = session?.role ?? 'viewer'\r\n return resolve(event)\r\n}\r\n```\r\n\r\n## The `authorize` hook (hand-written apps too)\r\n\r\nRBAC is built on a hook you can use without Studio. `createKitHandlers` accepts\r\nan `authorize` callback run before every op; return `false` (or throw) to reject:\r\n\r\n```ts\r\nimport { createKitHandlers } from '@svgrid/enterprise'\r\n\r\nexport const { POST } = createKitHandlers({\r\n schema: customersSchema,\r\n source,\r\n authorize: ({ action, event }) => {\r\n const role = event.locals?.role\r\n if (action === 'read') return true\r\n return role === 'admin' || role === 'editor'\r\n },\r\n})\r\n```\r\n\r\n`action` is `'read' | 'create' | 'update' | 'delete'`, and `event` carries the\r\nSvelteKit `locals` so you can read the session. It runs **before** the data\r\nsource is touched, so an unauthorized request never reaches your database.\r\n\r\n## Layering with Row-Level Security\r\n\r\nRBAC decides *which actions* a role may perform. To also scope *which rows* a\r\nuser sees, combine it with database [Row-Level Security](./auth.md#scope-data-per-user-with-rls) -\r\nRBAC in the app for actions + screens, RLS in Postgres for row visibility. The\r\ntwo are complementary: keep both on for defense in depth.\r\n\r\n## Multi-tenancy\r\n\r\nRBAC answers \"what may this role do?\". Multi-tenancy answers a different\r\nquestion - \"whose rows are these?\" - and the two compose: a role gates the\r\naction, the tenant gates the data.\r\n\r\n```ts\r\nproject.tenancy = { enabled: true } // column: tenantId\r\nproject.tenancy = { enabled: true, field: 'orgId' } // custom column\r\nproject.tenancy = { enabled: true, sharedEntities: ['currencies'] }\r\n```\r\n\r\nOne database, one deployment, rows partitioned by a tenant column. The tenant\r\ncomes from the signed-in user's session - never from anything the client sends.\r\n\r\n**It is enforced on the server, on all four paths.** Scoping reads alone is not\r\nisolation, so the generated API route also:\r\n\r\n| Operation | What the route does |\r\n| --- | --- |\r\n| read | merges the tenant predicate into the query, written **last** so a client-supplied `tenantId` filter cannot widen it |\r\n| create | stamps the tenant onto the row, overriding whatever was sent, and **after** any business-rule hook |\r\n| update / delete | re-reads the target row under the scope first and returns `403` if it isn't yours - otherwise guessing an id would reach across tenants |\r\n| update (patch) | re-stamps the tenant, so a patch cannot hand a row to someone else |\r\n\r\nIf the tenant cannot be resolved, `requireTenant` **throws** and the request\r\nfails with `403`. That is deliberate: returning \"no tenant\" would run the query\r\nunscoped, which is the one failure mode multi-tenancy cannot have.\r\n\r\n### What it generates\r\n\r\n- `src/lib/server/tenant.ts` - `getTenant` / `requireTenant` off `event.locals`\r\n- `scope: ...` on every scoped entity's `+server.ts`\r\n- a not-null tenant column on each scoped table **and** on `auth_users`, in the\r\n same migration as everything else\r\n- the tenant on `event.locals` in `hooks.server.ts`\r\n\r\nThe column is added to the database schema, not to the entity's field list, so\r\nit stays out of forms and grids - it is infrastructure, not data your users edit.\r\n\r\n### Requirements\r\n\r\nNeeds the [auth starter](./auth.md) (to know the tenant), the\r\n[typed data layer](./drizzle.md) (so the column exists), and at least one\r\nSQL-bound entity. Missing any of them it degrades to **off** rather than\r\nemitting a half-enforced scope; the `studio_set_tenancy` MCP tool says so\r\nexplicitly rather than letting you believe an unscoped app is scoped.\r\n\r\n`sharedEntities` stay global - reference tables like currencies or countries\r\nthat every tenant reads get no column and no scope.\r\n\r\n### Using the primitive directly\r\n\r\nOutside Studio, the same mechanism is one option on `createKitHandlers`:\r\n\r\n```ts\r\nexport const { POST } = createKitHandlers({\r\n schema, source,\r\n scope: ({ event }) => ({ field: 'tenantId', value: requireTenant(event) }),\r\n})\r\n```\r\n\r\nReturn `null` to skip scoping for a caller (a super-admin), or throw to reject.\r\n\r\n## See also\r\n\r\n- [Auth & secured screens](./auth.md) - authentication + RLS\r\n- [Code generation](./code-generation.md) Ā· [Databases](./databases.md) - the API routes RBAC guards\r\n- [The visual designer](./app-designer.md) - where you author the policy\r\n"
2673
2694
  },
2674
2695
  {
2675
2696
  "slug": "enterprise/studio/accessibility",
@@ -2681,7 +2702,7 @@ export const docs = [
2681
2702
  "slug": "enterprise/studio/ai-generation",
2682
2703
  "path": "docs/enterprise/studio/ai-generation.md",
2683
2704
  "title": "AI generation",
2684
- "markdown": "# AI generation\r\n\r\nThe `@svgrid/mcp` server exposes Studio to AI coding agents (Claude Code, Cursor,\r\nCodex, ...) through the Model Context Protocol. Ask your agent to build a screen\r\nfor a table and it introspects, scaffolds, and verifies - producing the same code\r\nthe [CLI](./cli.md) and [designer](./designer.md) do.\r\n\r\n![The generated files: schema module, +server.ts API route, and +page.svelte screen, with svgrid:managed markers.](/docs-media/studio-generated-code.png)\r\n\r\n## How it fits together\r\n\r\nThe MCP server makes **no model calls of its own**. It hands your agent a set of\r\ntools; the agent's own model decides when to call them. So the loop is:\r\n\r\n```\r\nyou -> your agent (its model) -> svgrid MCP tools -> files on disk\r\n ^ |\r\n +-------- svelte-check verify <----------+\r\n```\r\n\r\nYour schema and data stay on your machine; nothing is sent to our servers.\r\n\r\n## Configure the MCP server\r\n\r\nAdd it to your agent's MCP config (the key is passed as an env var, since the\r\nserver runs in a Node process):\r\n\r\n```jsonc\r\n{\r\n \"mcpServers\": {\r\n \"svgrid\": {\r\n \"command\": \"npx\",\r\n \"args\": [\"@svgrid/mcp\"],\r\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\r\n }\r\n }\r\n}\r\n```\r\n\r\nThe same block works across hosts - only the file it lives in differs:\r\n\r\n| Host | Config location |\r\n| --- | --- |\r\n| Claude Code | `.mcp.json` at the project root, or `claude mcp add` |\r\n| Cursor | `.cursor/mcp.json` |\r\n| Windsurf | `~/.codeium/windsurf/mcp_config.json` |\r\n| Codex / other | the host's `mcpServers` config |\r\n\r\nRestart (or reload) the agent so it picks up the server, then confirm the\r\n`svgrid` tools are listed.\r\n\r\n## The tools\r\n\r\nAlongside the read-only knowledge tools (examples, docs, API reference), the\r\nserver exposes two generation tools:\r\n\r\n| Tool | What it does |\r\n| --- | --- |\r\n| `introspect_source` | Infer an `EntitySchema` from a Drizzle schema file (`kind:\"drizzle\"`) or sample JSON rows (`kind:\"json\"`). Returns a **draft** to review. |\r\n| `scaffold_entity` | Generate the SvelteKit files from an `EntitySchema`. The output is **compile-verified** (the generated page is run through the Svelte compiler) before it comes back, and each file carries `svgrid:managed` markers. |\r\n\r\n## Drive the whole project model\r\n\r\nBeyond single screens, the server exposes the full\r\n[project model](./concepts.md#the-project-model) - the same\r\n`studio.config.json` the visual designer edits - as a set of `studio_*` tools.\r\nYour agent can build a complete multi-screen app, or continue editing one the\r\ndesigner produced, and hand it back:\r\n\r\n| Tool | What it does |\r\n| --- | --- |\r\n| `studio_new_project` | Start a new, empty project |\r\n| `studio_load_project` | Load an existing `studio.config.json` to continue editing it |\r\n| `studio_describe_project` | Summarize the current project: entities, screens + block ids, theme, RBAC, auth, deploy |\r\n| `studio_get_config` | Return the project as a `studio.config.json` string - write it to disk and the designer opens it (round-trip) |\r\n| `studio_capabilities` | List what can be added: block kinds, UI component keys, theme presets, source kinds, deploy targets |\r\n| `studio_add_entity` | Add an entity + its default screen, from an `EntitySchema`, a Drizzle source, or sample JSON rows |\r\n| `studio_add_screen` | Add an entity-bound screen (default grid) or a freestanding page |\r\n| `studio_add_block` | Add a data block (grid, chart, kpi, gauge, tree, tabs, accordion, pivot, board, calendar, detail, master-detail, filter, record, lookup, dashboard) to a screen |\r\n| `studio_add_component` | Add a UI component block (button, badge, alert, card, stat, timeline, sparkline, chip, ...) with prop overrides |\r\n| `studio_set_entity_source` | Bind an entity to a data source: `sql`, `supabase`, `rest`, `pglite`, or `memory` |\r\n| `studio_set_theme` | Set the theme preset, light/dark mode, and accent color |\r\n| `studio_set_access` | Configure [RBAC](./access-control.md): roles gating screens and create/update/delete actions |\r\n| `studio_set_auth` | Configure the [auth starter](./auth.md): protect, register, user admin, 2FA, email, OAuth (`github` / `google` / `oidc`) |\r\n| `studio_set_data_layer` | Turn the typed Drizzle data layer (schema + repositories + migrations) on or off |\r\n| `studio_set_deploy_target` | Set `auto` / `vercel` / `netlify` / `cloudflare` / `node` - picks the adapter and emits CI/CD config |\r\n| `studio_validate` | Validate the current project; returns errors + warnings |\r\n| `studio_generate_app` | Emit the full runnable SvelteKit app - every file, ready to write and `svelte-check` |\r\n\r\nA prompt that exercises the loop end to end:\r\n\r\n> \"Using the svgrid MCP: new project 'Support desk'. Add a `tickets` entity from\r\n> these sample rows, a dashboard screen with a KPI and a chart over tickets,\r\n> RBAC with an agent role that cannot delete, dark theme, then validate and\r\n> generate the app.\"\r\n\r\n## Step by step\r\n\r\n1. **Point it at a source.** A Drizzle schema file, or a handful of sample rows.\r\n2. **Introspect.** The agent calls `introspect_source` and shows you the drafted\r\n `EntitySchema` - field names, types, primary key, guessed formats.\r\n3. **Refine (optional).** Correct a type, mark a field hidden or read-only, add\r\n validation - in chat, or later in the [visual designer](./app-designer.md).\r\n4. **Scaffold.** The agent calls `scaffold_entity`; the files come back already\r\n run through the Svelte compiler.\r\n5. **Verify.** The agent runs your project's `svelte-check`; if anything fails it\r\n iterates. This is the loop that keeps AI output trustworthy.\r\n\r\n## Prompts that work\r\n\r\nFrom a Drizzle schema:\r\n\r\n> \"Using the svgrid MCP, build a CRUD screen for the `customers` table in\r\n> `src/lib/db/schema.ts`.\"\r\n\r\nFrom sample data, when there is no schema yet:\r\n\r\n> \"Here are five example rows of our invoices. Use the svgrid MCP to introspect a\r\n> schema, then scaffold a CRUD screen at `/invoices`.\"\r\n>\r\n> ```json\r\n> [{ \"id\": \"INV-1\", \"customer\": \"Acme\", \"amount\": 4200, \"paid\": true, \"due\": \"2026-07-01\" }]\r\n> ```\r\n\r\nRefining before you commit:\r\n\r\n> \"Show me the drafted schema first. Mark `internalNotes` hidden, make `email`\r\n> required, and set `status` to an enum of draft/sent/paid before scaffolding.\"\r\n\r\n## What comes back\r\n\r\n`scaffold_entity` writes three files (the same layout as the CLI and designer):\r\n\r\n```\r\nsrc/lib/customers.schema.ts # the EntitySchema + row type\r\nsrc/routes/api/customers/+server.ts # createKitHandlers data endpoint\r\nsrc/routes/customers/+page.svelte # the grid + edit-panel screen\r\n```\r\n\r\nEach carries `svgrid:managed` markers so a re-generation updates the managed\r\nregions and leaves your hand-written code untouched. See\r\n[code generation](./code-generation.md) for the anatomy of each file.\r\n\r\n## Bring your own key\r\n\r\nThe generator uses **your** agent's model and API key - your schema and data\r\nnever touch our servers. The MCP server itself makes no model calls; it provides\r\nintrospection + scaffolding + verification tools that the host agent drives.\r\n\r\n## Licensing\r\n\r\nGeneration is soft-gated: it runs unlicensed and prepends a one-line commercial\r\nnotice, and the generated app carries the usual watermark until you call\r\n`setLicenseKey()`. Set `SVGRID_LICENSE_KEY` in the MCP config to license it. See\r\n[licensing](../licensing.md#studio-data-app-generator).\r\n\r\n## See also\r\n\r\n- [The Studio CLI](./cli.md) - the deterministic, no-AI path\r\n- [Visual app designer](./app-designer.md) - refine an AI draft by hand before generating\r\n- [Code generation](./code-generation.md) - the anatomy of the emitted files\r\n- [MCP server](../../help/mcp-server.md) - full MCP reference\r\n"
2705
+ "markdown": "# AI generation\r\n\r\nThe `@svgrid/mcp` server exposes Studio to AI coding agents (Claude Code, Cursor,\r\nCodex, ...) through the Model Context Protocol. Ask your agent to build a screen\r\nfor a table and it introspects, scaffolds, and verifies - producing the same code\r\nthe [CLI](./cli.md) and [designer](./designer.md) do.\r\n\r\n![The generated files: schema module, +server.ts API route, and +page.svelte screen, with svgrid:managed markers.](/docs-media/studio-generated-code.png)\r\n\r\n## How it fits together\r\n\r\nThe MCP server makes **no model calls of its own**. It hands your agent a set of\r\ntools; the agent's own model decides when to call them. So the loop is:\r\n\r\n```\r\nyou -> your agent (its model) -> svgrid MCP tools -> files on disk\r\n ^ |\r\n +-------- svelte-check verify <----------+\r\n```\r\n\r\nYour schema and data stay on your machine; nothing is sent to our servers.\r\n\r\n## Configure the MCP server\r\n\r\nAdd it to your agent's MCP config (the key is passed as an env var, since the\r\nserver runs in a Node process):\r\n\r\n```jsonc\r\n{\r\n \"mcpServers\": {\r\n \"svgrid\": {\r\n \"command\": \"npx\",\r\n \"args\": [\"@svgrid/mcp\"],\r\n \"env\": { \"SVGRID_LICENSE_KEY\": \"SVENTERPRISE-...\" }\r\n }\r\n }\r\n}\r\n```\r\n\r\nThe same block works across hosts - only the file it lives in differs:\r\n\r\n| Host | Config location |\r\n| --- | --- |\r\n| Claude Code | `.mcp.json` at the project root, or `claude mcp add` |\r\n| Cursor | `.cursor/mcp.json` |\r\n| Windsurf | `~/.codeium/windsurf/mcp_config.json` |\r\n| Codex / other | the host's `mcpServers` config |\r\n\r\nRestart (or reload) the agent so it picks up the server, then confirm the\r\n`svgrid` tools are listed.\r\n\r\n## The tools\r\n\r\nAlongside the read-only knowledge tools (examples, docs, API reference), the\r\nserver exposes two generation tools:\r\n\r\n| Tool | What it does |\r\n| --- | --- |\r\n| `introspect_source` | Infer an `EntitySchema` from a Drizzle schema file (`kind:\"drizzle\"`) or sample JSON rows (`kind:\"json\"`). Returns a **draft** to review. |\r\n| `scaffold_entity` | Generate the SvelteKit files from an `EntitySchema`. The output is **compile-verified** (the generated page is run through the Svelte compiler) before it comes back, and each file carries `svgrid:managed` markers. |\r\n\r\n## Drive the whole project model\r\n\r\nBeyond single screens, the server exposes the full\r\n[project model](./concepts.md#the-project-model) - the same\r\n`studio.config.json` the visual designer edits - as a set of `studio_*` tools.\r\nYour agent can build a complete multi-screen app, or continue editing one the\r\ndesigner produced, and hand it back:\r\n\r\n| Tool | What it does |\r\n| --- | --- |\r\n| `studio_new_project` | Start a new, empty project |\r\n| `studio_load_project` | Load an existing `studio.config.json` to continue editing it |\r\n| `studio_describe_project` | Summarize the current project: entities, screens + block ids, theme, RBAC, auth, deploy |\r\n| `studio_get_config` | Return the project as a `studio.config.json` string - write it to disk and the designer opens it (round-trip) |\r\n| `studio_capabilities` | List what can be added: block kinds, UI component keys, theme presets, source kinds, deploy targets |\r\n| `studio_add_entity` | Add an entity + its default screen, from an `EntitySchema`, a Drizzle source, or sample JSON rows |\r\n| `studio_add_screen` | Add an entity-bound screen (default grid) or a freestanding page |\r\n| `studio_add_block` | Add a data block (grid, chart, kpi, gauge, tree, tabs, accordion, pivot, board, calendar, detail, master-detail, filter, record, lookup, dashboard) to a screen |\r\n| `studio_add_component` | Add a UI component block (button, badge, alert, card, stat, timeline, sparkline, chip, ...) with prop overrides |\r\n| `studio_update_block` | **Configure** an existing block - columns, editing mode, export buttons, grouping, chart dimension/measure, row links, format rules - plus its width, height, and class |\r\n| `studio_remove_block` | Remove a block from a screen |\r\n| `studio_move_block` | Reorder a block within its screen |\r\n| `studio_update_screen` | Rename a screen, change its route or nav entry, or set `renderMode` (`ssr` for an idiomatic `+page.server.ts` load + form actions, `spa` for the client page) |\r\n| `studio_remove_screen` | Remove a screen and its blocks |\r\n| `studio_set_screen_layout` | Switch a screen between `grid`, `stack`, `split`, `dock`, and `canvas` layouts |\r\n| `studio_set_entity_source` | Bind an entity to a data source: `sql`, `supabase`, `rest`, `pglite`, or `memory` |\r\n| `studio_set_theme` | Set the theme preset, light/dark mode, and accent color |\r\n| `studio_set_access` | Configure [RBAC](./access-control.md): roles gating screens and create/update/delete actions |\r\n| `studio_set_auth` | Configure the [auth starter](./auth.md): protect, register, user admin, 2FA, email, OAuth (`github` / `google` / `oidc`) |\r\n| `studio_set_data_layer` | Turn the typed Drizzle data layer (schema + repositories + migrations) on or off |\r\n| `studio_set_tenancy` | Turn [multi-tenancy](./access-control.md#multi-tenancy) on/off - scopes every row to the caller's tenant, enforced server-side; `sharedEntities` stay global |\r\n| `studio_set_job` | Schedule a background job (`email` digest or `code`) - emits the guarded `/api/cron` route + the platform schedule; omit `cron` to remove one |\r\n| `studio_set_deploy_target` | Set `auto` / `vercel` / `netlify` / `cloudflare` / `node` - picks the adapter and emits CI/CD config |\r\n| `studio_validate` | Validate the current project; returns errors + warnings |\r\n| `studio_generate_app` | Emit the full runnable SvelteKit app - every file, ready to write and `svelte-check` |\r\n\r\nA prompt that exercises the loop end to end:\r\n\r\n> \"Using the svgrid MCP: new project 'Support desk'. Add a `tickets` entity from\r\n> these sample rows, a dashboard screen with a KPI and a chart over tickets,\r\n> RBAC with an agent role that cannot delete, dark theme, then validate and\r\n> generate the app.\"\r\n\r\n## Step by step\r\n\r\n1. **Point it at a source.** A Drizzle schema file, or a handful of sample rows.\r\n2. **Introspect.** The agent calls `introspect_source` and shows you the drafted\r\n `EntitySchema` - field names, types, primary key, guessed formats.\r\n3. **Refine (optional).** Correct a type, mark a field hidden or read-only, add\r\n validation - in chat, or later in the [visual designer](./app-designer.md).\r\n4. **Scaffold.** The agent calls `scaffold_entity`; the files come back already\r\n run through the Svelte compiler.\r\n5. **Verify.** The agent runs your project's `svelte-check`; if anything fails it\r\n iterates. This is the loop that keeps AI output trustworthy.\r\n\r\n## Prompts that work\r\n\r\nFrom a Drizzle schema:\r\n\r\n> \"Using the svgrid MCP, build a CRUD screen for the `customers` table in\r\n> `src/lib/db/schema.ts`.\"\r\n\r\nFrom sample data, when there is no schema yet:\r\n\r\n> \"Here are five example rows of our invoices. Use the svgrid MCP to introspect a\r\n> schema, then scaffold a CRUD screen at `/invoices`.\"\r\n>\r\n> ```json\r\n> [{ \"id\": \"INV-1\", \"customer\": \"Acme\", \"amount\": 4200, \"paid\": true, \"due\": \"2026-07-01\" }]\r\n> ```\r\n\r\nRefining before you commit:\r\n\r\n> \"Show me the drafted schema first. Mark `internalNotes` hidden, make `email`\r\n> required, and set `status` to an enum of draft/sent/paid before scaffolding.\"\r\n\r\n## What comes back\r\n\r\n`scaffold_entity` writes three files (the same layout as the CLI and designer):\r\n\r\n```\r\nsrc/lib/customers.schema.ts # the EntitySchema + row type\r\nsrc/routes/api/customers/+server.ts # createKitHandlers data endpoint\r\nsrc/routes/customers/+page.svelte # the grid + edit-panel screen\r\n```\r\n\r\nEach carries `svgrid:managed` markers so a re-generation updates the managed\r\nregions and leaves your hand-written code untouched. See\r\n[code generation](./code-generation.md) for the anatomy of each file.\r\n\r\n## Bring your own key\r\n\r\nThe generator uses **your** agent's model and API key - your schema and data\r\nnever touch our servers. The MCP server itself makes no model calls; it provides\r\nintrospection + scaffolding + verification tools that the host agent drives.\r\n\r\n## Licensing\r\n\r\nGeneration is soft-gated: it runs unlicensed and prepends a one-line commercial\r\nnotice, and the generated app carries the usual watermark until you call\r\n`setLicenseKey()`. Set `SVGRID_LICENSE_KEY` in the MCP config to license it. See\r\n[licensing](../licensing.md#studio-data-app-generator).\r\n\r\n## See also\r\n\r\n- [The Studio CLI](./cli.md) - the deterministic, no-AI path\r\n- [Visual app designer](./app-designer.md) - refine an AI draft by hand before generating\r\n- [Code generation](./code-generation.md) - the anatomy of the emitted files\r\n- [MCP server](../../help/mcp-server.md) - full MCP reference\r\n"
2685
2706
  },
2686
2707
  {
2687
2708
  "slug": "enterprise/studio/api",
@@ -2693,7 +2714,7 @@ export const docs = [
2693
2714
  "slug": "enterprise/studio/app-designer",
2694
2715
  "path": "docs/enterprise/studio/app-designer.md",
2695
2716
  "title": "Visual app designer",
2696
- "markdown": "# Visual app designer\r\n\r\n`SvStudioDesigner` is the grid-centric visual **data-app** designer - compose a\r\nmulti-entity app by arranging data-bound blocks on a canvas, then generate a\r\nrunnable SvelteKit project. It's the app-level companion to the single-entity\r\n[schema designer](./designer.md): where that authors one `EntitySchema`, this\r\ncomposes **screens** across **many entities**.\r\n\r\nGrid-centric by design: the blocks are schema-driven and data-bound (a grid, a\r\nchart, a pivot, a dashboard, a KPI, master-detail, a faceted filter panel, a\r\nrecord panel, a lookup) - not arbitrary layout components. Point it at a\r\ndatabase, get a CRUD app - kept to data views.\r\n\r\n> **Just want to open it?** `npx @svgrid/studio designer` launches this designer\r\n> in your browser, auto-saves your work to `studio.config.json`, and writes the\r\n> generated app to a folder - no host app needed. See\r\n> [Launch the designer](./launch.md).\r\n\r\n## How the screen is laid out\r\n\r\nHere is the real designer with a small Sales App open:\r\n\r\n![The visual app designer: a Pages and Entities rail on the left, Design / Code tabs above a live grid preview (a customer grid with status chips) in the middle, and a screen properties panel on the right.](/docs-media/studio-app-designer.png)\r\n\r\nYou do not need to understand the internals to use it. The same layout, labelled:\r\n\r\n![The visual designer's layout: a screens list on the left, a palette of blocks to add, a live preview in the middle, a properties panel on the right, and a Generate app button in the top bar.](/docs-media/studio-designer-anatomy.svg)\r\n\r\n- **Screens** (far left) - the pages of your app. Click one to edit it; **+ Add\r\n screen** makes a new one.\r\n- **Blocks** - the pieces you drop onto a screen: a **grid** (a table of records),\r\n a **chart**, a **pivot**, a **KPI** number, a **dashboard**, a **filter panel**,\r\n and a **record panel**. Click or drag one onto the preview.\r\n- **Live preview** (middle) - your screen with **real data**, updating as you\r\n change things. What you see is what the app will look like.\r\n- **Properties** (right) - tune the selected block, or - with nothing selected -\r\n edit the entity's **fields** and pick its **data source**.\r\n- **Generate app** (top right) - when it looks right, one click writes the whole,\r\n runnable app.\r\n\r\nThe rest of this page is the detailed reference for each area, aimed at developers\r\nembedding or scripting the designer. If you just want to build an app, everything\r\nabove is done by pointing and clicking - see\r\n[Launch the designer](./launch.md).\r\n\r\n## What it edits: the project model\r\n\r\nThe designer reads and writes a `StudioProject` - the declarative model behind\r\nthe whole app:\r\n\r\n```ts\r\nimport { createProject } from '@svgrid/enterprise'\r\n\r\n// One default screen (grid + edit form) per entity, in-memory.\r\nlet project = $state(createProject([customerSchema, orderSchema], { title: 'Sales App' }))\r\n```\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvStudioDesigner } from '@svgrid/enterprise'\r\n</script>\r\n\r\n<SvStudioDesigner {project} onChange={(p) => (project = p)} />\r\n```\r\n\r\nThe designer is a single IDE-style frame: a **title bar** (app name + accent +\r\nundo/redo + Import/Load/Save/Generate), a **screen tab strip** (switch, close, or\r\nadd a screen), the three work panels, and a **status bar** (validity, entity /\r\nscreen / block counts, current selection, data source).\r\n\r\n- **Rail** (left) - switch and add screens; each screen is bound to an entity.\r\n- **Screen tabs** - the open screens as a document strip; click to switch, the\r\n **x** to remove one, the **+** to add one (from the current template).\r\n- **Palette** - the block kinds. **Drag** one onto the canvas to add it (or click).\r\n- **Canvas** - the screen's blocks in a responsive **12-column** grid, previewed\r\n live with the real components. **Drag a block** to reorder it; **drag its right\r\n edge** to set its width (1-12 columns), use the ā…“ / ½ / ā…” / full quick buttons\r\n in its header, or the **Layout > Width** slider. **Drag a block's bottom edge**\r\n to make its region taller or shorter - grid, chart, pivot, and master-detail\r\n blocks are height-resizable, and the chosen height flows through to the generated app\r\n (also set it precisely under **Layout > Height** in the inspector).\r\n- **Inspector** (right) - edit the selected block: a grid's **editing mode** +\r\n behavior + **column config** (see below), a chart's group-by / measure / reduce\r\n / type. With no block selected, edit the **page** (title / route /\r\n **nav** settings), the entity's **data source** (see below), and the **entity's\r\n fields** - add, rename, retype, flag (PK / required / read-only), pick a\r\n **relation's target entity + label field**, and **drag to reorder**. The rail\r\n sets the **default source kind** for new entities, the **app layout**, and adds\r\n screens from a **template** (CRUD, dashboard, master-detail, empty).\r\n- **Top bar** - rename the app, set an **accent color** (themes the whole app),\r\n add a **New entity** from scratch, **Import CSV** (drop in a spreadsheet - see\r\n below), **Connect DB** (the launcher's live-database wizard - see\r\n [Launch the designer](./launch.md)), **Import schema** (paste a Drizzle / Prisma\r\n schema to add its entities), **Save / Load** the design as `studio.config.json`,\r\n and **Generate app**. With no entities yet, the canvas shows an **onboarding**\r\n screen offering the same ways to start.\r\n- **✨ Copilot** (when the host wires it) - describe a change in plain English\r\n (\"add an orders screen with a revenue chart\", \"make mrr required\") and the AI\r\n edits your project. It's a host hook: `<SvStudioDesigner onCopilot={...} />`\r\n receives `{ prompt, project }` and returns the edited `StudioProject` - your AI\r\n keys stay server-side. The result is validated before it applies, and it's one\r\n **Ctrl/Cmd+Z** away.\r\n\r\nThe three panels are **resizable** (drag the dividers). Every edit is **undoable**\r\n(Ctrl/Cmd+Z, Ctrl+Shift+Z / Ctrl+Y to redo); **Delete** removes the selected\r\nblock, **Ctrl/Cmd+D** (or the **⧉** header button) **duplicates** it, and\r\n**Escape** deselects. **Preview app** opens the whole app full-screen with a\r\n**Desktop / Tablet / Mobile** device-width toggle to check responsiveness. **Generate app** opens the output in a\r\n**file-tree viewer** modal (scrollable, Copy per file) and a **Download .zip** of\r\nthe **complete runnable SvelteKit + Vite project** - unzip, `npm install`,\r\n`npm run dev`. The zip includes `package.json` (with the right driver deps),\r\n`vite.config.ts`, `svelte.config.js`, `tsconfig.json`, the app shell, and every\r\ngenerated screen.\r\n\r\n## The grid (and how it edits)\r\n\r\nThe grid is the core block, so **editing is a grid property**, not a separate\r\nblock. Select a grid and set its **Editing mode**:\r\n\r\n- **Popup form** - double-click a row to edit it in a modal / drawer / inline\r\n panel (pick the **Form style**); a **+ New** button adds rows. This is the\r\n default.\r\n- **Inline** - edit cells right in the grid (Excel-style); each change saves via\r\n the data source.\r\n- **Read-only** - no editing.\r\n\r\nThe grid's property editor also covers **Behavior** (Sortable, Filtering + search,\r\nRow selection, Cell range selection, Striped rows, Totals footer row, Density),\r\n**Paging** (Paginate on / off, Page size, **Pager position** - bottom / top / both,\r\nand the **Page size options** for the selector), and per-**Column** settings -\r\nexpand a column to set its **header**, **width**, **alignment**, **pin** (left /\r\nright), plus show / hide + reorder. There is no standalone \"Edit form\" block - the\r\ngrid owns editing end to end.\r\n\r\n## The analytical + companion blocks\r\n\r\nBeyond the grid, every block is still bound to the `EntitySchema` - these are data\r\nviews, not generic widgets:\r\n\r\n| Block | What it renders | Inspector |\r\n| --- | --- | --- |\r\n| **Chart** | A chart (`SvSchemaChart`) - bar, pie, line, area, radar, funnel, waterfall, or treemap. | Group-by dimension, measure, reduce, type. |\r\n| **Pivot** | A full pivot table (`SvPivotDesigner`) the end user can re-pivot live. | Row + column dimensions (checkboxes), a measure, and its aggregate. |\r\n| **Dashboard** | A schema-driven KPI + chart board (`SvSchemaDashboard`). | - |\r\n| **KPI** | A single reduced metric tile. | Label, measure, reduce. |\r\n| **Gauge** | A radial gauge (`SvGauge`) of one reduced measure within a range - utilization, progress, scores. | Label, measure, reduce, min / max, unit. |\r\n| **Tree** | A hierarchical tree (`SvTree`) built from the entity's own rows via a self-referential parent. | A label field + a parent field (a row's link to its parent row). |\r\n| **Tabs** | A tabbed container (`SvTabs`) that **groups display blocks** into tabs - e.g. an Overview tab of KPIs + a Details tab with a chart. | Add / rename / remove tabs; per tab, add child blocks (charts, KPIs, gauges, pivots, trees). |\r\n| **Accordion** | A collapsible-sections container (like Tabs, stacked vertically). | Add / rename / remove sections; child blocks per section. |\r\n| **Master / detail** | A row that expands into a nested grid of related records. | Child entity + foreign key. |\r\n| **Board** | A kanban board of the entity's rows, one lane per value of a group-by field, with drag between lanes. | Group-by, card title / subtitle / badge fields, open-screen drill. |\r\n| **Calendar** | A month event-calendar: each row with a date lands on its day, labelled and optionally color-coded. | Date field, title field, color field, open-screen drill. |\r\n| **Detail** | A full record \"detail page\": header, metric row, field sections, and related-record tabs - the 360 view a row action or drill-through opens. | Title / subtitle / status / metric fields, sections, related child entities. |\r\n| **Form** | A standalone create / edit form for the entity. | Presentation (drawer / modal / inline). |\r\n| **Filter panel** | A faceted sidebar that **filters the screen's grid** - enum / boolean facets pick a value, text facets search. | Title + which fields become facets. |\r\n| **Record panel** | Shows the row **selected in the grid** - a read-only field list, or an inline edit form. | Editable on / off, and (read-only) which fields to show. |\r\n| **Lookup** | Marks a relation field as a searchable picker in the edit form. | The relation field. |\r\n| **UI component** | A component from the SvGrid UI kit (button, badge, alert, card, stat, timeline, sparkline, chip, ...) dropped from the toolbox - entity-agnostic, works on freestanding pages too. | The component's own props, plus data bindings. |\r\n\r\nThe **filter** and **record** panels wire to the grid on the same screen: the\r\nfilter panel calls the grid controller's `setFilter`, and clicking a grid row\r\npublishes it to the record panel. So a common layout is a **filter panel + grid +\r\nrecord panel** three-up - list, narrow, inspect - all generated for you.\r\n\r\nThe grid, chart, pivot, and master-detail blocks are **height-resizable** (drag\r\nthe block's bottom edge, or set **Layout > Height**), and the chosen height flows\r\ninto the generated app. The filter and record panels size to their content.\r\n\r\n### Conditional formatting\r\n\r\nA grid's inspector has a **Conditional formatting** section: add no-code rules\r\nthat style a cell by its value - pick a field, a comparison (`=`, `<`, `>`,\r\n`contains`, `is empty`, ...), a value, and a **text color / fill / bold**. Rules\r\nrender **live in the canvas** and compile to the grid's built-in\r\n`conditionalFormats` rule engine in the generated app (e.g. negative `mrr` red,\r\n`status = overdue` filled). It's the same engine you'd use by hand - the designer\r\njust authors the rules.\r\n\r\n### Navigation & row actions\r\n\r\nThe grid's **Navigation & actions** section wires flow between screens:\r\n**drill-through** (row click opens another screen, filtered to the clicked value)\r\nand **row action buttons** (Edit / Delete / Open). A chart can drill too. See\r\n[Navigation & row actions](./navigation.md) for the full picture.\r\n\r\n## Data sources (per entity)\r\n\r\nEach entity binds to **its own backend** - the designer is not limited to one\r\ndata source per app. In the inspector (no block selected), the **Data source**\r\nsection binds the screen's entity to:\r\n\r\n- **In-memory** - seeded sample rows, runs with no backend (the default).\r\n- **Local database (no setup)** - a real, persistent Postgres ([PGlite](https://pglite.dev))\r\n running in the browser and saved to IndexedDB, so rows survive reloads with zero\r\n backend. Same SQL as production - swap to a hosted **SQL** source later without\r\n touching the schema. See [Local database](./local-database.md).\r\n The builder is a draggable, resizable, maximizable panel, and you can open it for\r\n any entity straight from the **Data model** dialog's **Configure** button.\r\n\r\n- **REST API** - a request builder: **method**, **base URL**, **path** (path\r\n params auto-derive from `{tokens}`), and **Query / Path / Header** tabs. **Send**\r\n runs it live and shows a **response table** plus the real rows in the canvas\r\n grid; **Import fields** rewrites the entity's schema to match the response keys.\r\n An **API format** picker (Manual / Offset + Limit / DummyJSON / json-server)\r\n wires a wire-format adapter so the grid does **real server-side paging and sort**;\r\n Manual keeps the rows-path / total-path mapping for a single fetched page.\r\n- **Supabase** - **List tables** reads your project's tables so you pick one, and\r\n **Import schema** pulls its real columns, primary key, foreign keys, and enums\r\n into the entity; **Preview** shows live rows (works in the online designer, since\r\n Supabase is HTTP). The project **URL + anon key are a shared connection** you set\r\n once for every Supabase entity. A **Live updates (Realtime)** toggle emits a live\r\n subscription. **Generate app** emits `createClient` in `connections.ts` reading\r\n `PUBLIC_SUPABASE_URL` / `PUBLIC_SUPABASE_ANON_KEY` from `.env` (the key is never\r\n inlined) and adds `@supabase/supabase-js`; access is protected by your RLS\r\n policies.\r\n- **SQL** - paste a connection string (the dialect is auto-detected) or fill the\r\n guided form, set the **Schema** (Postgres search path). In the **local** designer\r\n (`npx @svgrid/studio designer`) **Preview data** runs a real `SELECT` and shows\r\n your actual rows on the canvas, and a missing `pg` / `mysql2` / ... driver is a\r\n **one-click install**. **Generate app** emits a connected\r\n `src/routes/api/<table>/+server.ts` (the dialect's driver, reading\r\n `DATABASE_URL`) and points the grid at it; the driver dep is added for you. In\r\n the **online** designer, bind the entity and generate - the app connects for real\r\n at runtime. To scaffold from an existing DB via CLI: `npx @svgrid/studio add\r\n <table> --db <dialect> --url …`.\r\n\r\n**Generate app** then emits the matching adapter per entity in `src/lib/data.ts`\r\n(`createRestDataSource` / `createSqlDataSource` / `createSupabaseDataSource` /\r\n`createInMemoryDataSource`). SQL and Supabase entities read their connection from a\r\ngenerated `src/lib/connections.ts` (or the `+server.ts` route) - the SQL driver and\r\nthe Supabase client are wired for you; the only manual step is setting the\r\nconnection in `.env` (the bundle ships a `.env.example`). See\r\n[Databases](./databases.md) and [REST API](./rest-api.md).\r\n\r\n## Import a CSV / spreadsheet\r\n\r\nThe fastest way to start from **your own data**: **Import CSV** in the top bar\r\ntakes a `.csv` file and turns it into a running screen. The designer parses the\r\nfile (quoted fields, embedded newlines, and CRLF included), **infers a type per\r\ncolumn** from its values (number, boolean, date, or text - thousands separators\r\nand `yes/no/true/false` are understood), ensures a **primary key** (it reuses an\r\n`id` column or synthesizes one), and adds an entity with a full CRUD screen,\r\n**seeded with the real rows**. Unsafe headers (`First Name`, `E-mail`) become safe\r\nfield keys and the note tells you what was renamed.\r\n\r\nImported rows ship **in-memory** by default (no dependencies), so the app runs\r\nimmediately. Switch that entity's **Data source** to **Local database** to make\r\nthe same imported rows **persist** across reloads - the seed carries over. This is\r\nall client-side: `csvToEntity(name, text)` is a pure function exported from\r\n`@svgrid/enterprise`, so the same import works in the CLI and your own tools.\r\n\r\n> **Large files:** the imported rows are stored as the entity's seed, so they are\r\n> embedded in `studio.config.json` when you **Save** the design. That is fine for\r\n> reference data and samples; for a large dataset, import a representative sample\r\n> and point the entity at a **database** (Local database / SQL) for the full data.\r\n\r\n## Pages and layout\r\n\r\n- **Pages** - each screen is a route. In the inspector's **Page** section, toggle\r\n **Show in navigation**, set a **nav label** and **nav order**, or start a page\r\n from the **Empty** template. Hidden pages stay routable but drop out of the nav.\r\n- **Render mode** - eligible screens (a single plain grid, or read-only block\r\n screens, on a memory / SQL source) can switch from the default client page to\r\n **SSR**: an idiomatic `+page.server.ts` with `load` + form actions. The rules\r\n are in [Code generation](./code-generation.md#render-mode-spa-or-ssr-per-screen).\r\n- **App layout** - the rail's **App layout** section themes the generated shell:\r\n **Sidebar** or **Top navigation**, a **brand** name, a **company logo**\r\n (uploaded - stored inline and shown in the nav in place of the brand text), a\r\n **footer**, and (for the sidebar) the **nav position** (left / right). This\r\n drives the generated `src/routes/+layout.svelte`. The generated shell is\r\n **responsive**: on phones the sidebar collapses to a hamburger drawer, the\r\n top-nav links scroll, and each screen's block grid stacks to one column.\r\n\r\n## Save, regenerate, round-trip\r\n\r\nThe `StudioProject` is the persisted design. **Save config** exports a\r\n`studio.config.json`; regenerate the app from it any time:\r\n\r\n```bash\r\nnpm create @svgrid/studio@latest my-app -- --project ./studio.config.json\r\n```\r\n\r\nOr programmatically:\r\n\r\n```ts\r\nimport { serializeProject, parseProject, emitStudioProject } from '@svgrid/enterprise'\r\n\r\nconst json = serializeProject(project) // save the design\r\nconst project2 = parseProject(json) // reopen it\r\nconst files = emitStudioProject(project2) // -> the app's source files\r\n```\r\n\r\n**The exported app carries its own design.** The downloaded zip includes a\r\n`studio.config.json` at its root. To keep editing the app visually after you've\r\nworked on it locally, open the designer and **Load** that file - entities,\r\nscreens, blocks, theme, RBAC, i18n, and now the logo all come back exactly as\r\ngenerated. Because the designer regenerates the files under `src/`, keep any\r\nhand-written code in **new** files/modules you import, so a re-generate never\r\noverwrites it (or use the CLI's `svgrid:managed` markers - see\r\n[Code generation](./code-generation.md)).\r\n\r\n## Generate the app\r\n\r\n**Generate app** emits `src/lib/schemas.ts`, `src/lib/data.ts` (the right adapter\r\nper entity, plus `src/lib/connections.ts` when any entity is SQL / Supabase-bound),\r\nand one `src/routes/<route>/+page.svelte` **per screen** that composes that\r\nscreen's blocks with their config - a grid (with your visible columns, in order) +\r\nedit modal, plus any charts / pivots / dashboard / KPI tiles, and filter / record\r\npanels wired to the grid, all bound to the data - and the\r\nnav layout (sidebar or top-nav) + home. The pages are self-contained (they use\r\n`@svgrid/grid` + `@svgrid/enterprise` directly), so the output runs as a standard\r\nSvelteKit app.\r\n\r\n```ts\r\nimport { emitStudioProject } from '@svgrid/enterprise'\r\nconst files = emitStudioProject(project) // [{ path, contents, description }, ...]\r\n```\r\n\r\n## See also\r\n\r\n- [Code behind (Code view)](./code-behind.md) - write TypeScript against a typed `ctx` (grid API, events, `ctx.grid.sortable = true`, lifecycle)\r\n- [Sample apps + bind your data](./samples.md) - start from a ready-made app, then point it at your database\r\n- [Launch the designer](./launch.md) - `npx @svgrid/studio designer` (auto-save + generate to a folder)\r\n- [Schema designer](./designer.md) - author a single entity\r\n- [Dashboards](./dashboards.md) Ā· [Databases](./databases.md) - the blocks + data sources\r\n- [CLI](./cli.md) / [Drizzle](./drizzle.md) / [Prisma](./prisma.md) - import a schema to design from\r\n"
2717
+ "markdown": "# Visual app designer\r\n\r\n`SvStudioDesigner` is the grid-centric visual **data-app** designer - compose a\r\nmulti-entity app by arranging data-bound blocks on a canvas, then generate a\r\nrunnable SvelteKit project. It's the app-level companion to the single-entity\r\n[schema designer](./designer.md): where that authors one `EntitySchema`, this\r\ncomposes **screens** across **many entities**.\r\n\r\nGrid-centric by design: the blocks are schema-driven and data-bound (a grid, a\r\nchart, a pivot, a dashboard, a KPI, master-detail, a faceted filter panel, a\r\nrecord panel, a lookup) - not arbitrary layout components. Point it at a\r\ndatabase, get a CRUD app - kept to data views.\r\n\r\n> **Just want to open it?** `npx @svgrid/studio designer` launches this designer\r\n> in your browser, auto-saves your work to `studio.config.json`, and writes the\r\n> generated app to a folder - no host app needed. See\r\n> [Launch the designer](./launch.md).\r\n\r\n## Starting a new app\r\n\r\n**New app** in the top bar walks you from nothing to a working CRUD app: pick\r\nwhere the data comes from, choose the tables, choose the pages, open the result.\r\n\r\n1. **Start** - sample data, your own data, or a blank set of tables you name.\r\n2. **Data** - connect a database (the table picker shows row counts and lets you\r\n preview rows before importing), pick a starter dataset, point at a REST\r\n endpoint, or paste an OpenAPI document.\r\n3. **Screens** - tick which pages each table gets (list, form, record page,\r\n dashboard) and how rows are edited: a popup form, in the grid, or on the\r\n record page.\r\n4. **Done** - name it and open it. It arrives as one undo step, so Ctrl+Z puts\r\n the previous design back.\r\n\r\nConnecting to a live database needs the local designer (`npx @svgrid/studio dev`)\r\nbecause database drivers run on your machine, not in a browser tab. On\r\n[svgrid.com/studio](https://svgrid.com/studio) the other three paths work as-is,\r\nand you can rebind to your database later with **Use my data**.\r\n\r\nThe terminal equivalent is [`svgrid-studio init`](./cli.md#init) - same\r\nquestions, same generator, same app.\r\n\r\n## How the screen is laid out\r\n\r\nHere is the real designer with a small Sales App open:\r\n\r\n![The visual app designer: a Pages and Entities rail on the left, Design / Code tabs above a live grid preview (a customer grid with status chips) in the middle, and a screen properties panel on the right.](/docs-media/studio-app-designer.png)\r\n\r\nYou do not need to understand the internals to use it. The same layout, labelled:\r\n\r\n![The visual designer's layout: a screens list on the left, a palette of blocks to add, a live preview in the middle, a properties panel on the right, and a Generate app button in the top bar.](/docs-media/studio-designer-anatomy.svg)\r\n\r\n- **Screens** (far left) - the pages of your app. Click one to edit it; **+ Add\r\n screen** makes a new one.\r\n- **Blocks** - the pieces you drop onto a screen: a **grid** (a table of records),\r\n a **chart**, a **pivot**, a **KPI** number, a **dashboard**, a **filter panel**,\r\n and a **record panel**. Click or drag one onto the preview.\r\n- **Live preview** (middle) - your screen with **real data**, updating as you\r\n change things. What you see is what the app will look like.\r\n- **Properties** (right) - tune the selected block, or - with nothing selected -\r\n edit the entity's **fields** and pick its **data source**.\r\n- **Generate app** (top right) - when it looks right, one click writes the whole,\r\n runnable app.\r\n\r\nThe rest of this page is the detailed reference for each area, aimed at developers\r\nembedding or scripting the designer. If you just want to build an app, everything\r\nabove is done by pointing and clicking - see\r\n[Launch the designer](./launch.md).\r\n\r\n## What it edits: the project model\r\n\r\nThe designer reads and writes a `StudioProject` - the declarative model behind\r\nthe whole app:\r\n\r\n```ts\r\nimport { createProject } from '@svgrid/enterprise'\r\n\r\n// One default screen (grid + edit form) per entity, in-memory.\r\nlet project = $state(createProject([customerSchema, orderSchema], { title: 'Sales App' }))\r\n```\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvStudioDesigner } from '@svgrid/enterprise'\r\n</script>\r\n\r\n<SvStudioDesigner {project} onChange={(p) => (project = p)} />\r\n```\r\n\r\nThe designer is a single IDE-style frame: a **title bar** (app name + accent +\r\nundo/redo + Import/Load/Save/Generate), a **screen tab strip** (switch, close, or\r\nadd a screen), the three work panels, and a **status bar** (validity, entity /\r\nscreen / block counts, current selection, data source).\r\n\r\n- **Rail** (left) - switch and add screens; each screen is bound to an entity.\r\n- **Screen tabs** - the open screens as a document strip; click to switch, the\r\n **x** to remove one, the **+** to add one (from the current template).\r\n- **Palette** - the block kinds. **Drag** one onto the canvas to add it (or click).\r\n- **Canvas** - the screen's blocks in a responsive **12-column** grid, previewed\r\n live with the real components. **Drag a block** to reorder it; **drag its right\r\n edge** to set its width (1-12 columns), use the ā…“ / ½ / ā…” / full quick buttons\r\n in its header, or the **Layout > Width** slider. **Drag a block's bottom edge**\r\n to make its region taller or shorter - grid, chart, pivot, and master-detail\r\n blocks are height-resizable, and the chosen height flows through to the generated app\r\n (also set it precisely under **Layout > Height** in the inspector).\r\n- **Inspector** (right) - edit the selected block: a grid's **editing mode** +\r\n behavior + **column config** (see below), a chart's group-by / measure / reduce\r\n / type. With no block selected, edit the **page** (title / route /\r\n **nav** settings), the entity's **data source** (see below), and the **entity's\r\n fields** - add, rename, retype, flag (PK / required / read-only), pick a\r\n **relation's target entity + label field**, and **drag to reorder**. The rail\r\n sets the **default source kind** for new entities, the **app layout**, and adds\r\n screens from a **template** (CRUD, dashboard, master-detail, empty).\r\n- **Top bar** - rename the app, set an **accent color** (themes the whole app),\r\n add a **New entity** from scratch, **Import CSV** (drop in a spreadsheet - see\r\n below), **Connect DB** (the launcher's live-database wizard - see\r\n [Launch the designer](./launch.md)), **Import schema** (paste a Drizzle / Prisma\r\n schema to add its entities), **Save / Load** the design as `studio.config.json`,\r\n and **Generate app**. With no entities yet, the canvas shows an **onboarding**\r\n screen offering the same ways to start.\r\n- **✨ Copilot** (when the host wires it) - describe a change in plain English\r\n (\"add an orders screen with a revenue chart\", \"make mrr required\") and the AI\r\n edits your project. It's a host hook: `<SvStudioDesigner onCopilot={...} />`\r\n receives `{ prompt, project }` and returns the edited `StudioProject` - your AI\r\n keys stay server-side. The result is validated before it applies, and it's one\r\n **Ctrl/Cmd+Z** away.\r\n\r\nThe three panels are **resizable** (drag the dividers). Every edit is **undoable**\r\n(Ctrl/Cmd+Z, Ctrl+Shift+Z / Ctrl+Y to redo); **Delete** removes the selected\r\nblock, **Ctrl/Cmd+D** (or the **⧉** header button) **duplicates** it, and\r\n**Escape** deselects. **Preview app** opens the whole app full-screen with a\r\n**Desktop / Tablet / Mobile** device-width toggle to check responsiveness. **Generate app** opens the output in a\r\n**file-tree viewer** modal (scrollable, Copy per file) and a **Download .zip** of\r\nthe **complete runnable SvelteKit + Vite project** - unzip, `npm install`,\r\n`npm run dev`. The zip includes `package.json` (with the right driver deps),\r\n`vite.config.ts`, `svelte.config.js`, `tsconfig.json`, the app shell, and every\r\ngenerated screen.\r\n\r\n## The grid (and how it edits)\r\n\r\nThe grid is the core block, so **editing is a grid property**, not a separate\r\nblock. Select a grid and set its **Editing mode**:\r\n\r\n- **Popup form** - double-click a row to edit it in a modal / drawer / inline\r\n panel (pick the **Form style**); a **+ New** button adds rows. This is the\r\n default.\r\n- **Inline** - edit cells right in the grid (Excel-style); each change saves via\r\n the data source.\r\n- **Read-only** - no editing.\r\n\r\nThe grid's property editor also covers **Behavior** (Sortable, Filtering + search,\r\nRow selection, Cell range selection, Striped rows, Totals footer row, Density),\r\n**Paging** (Paginate on / off, Page size, **Pager position** - bottom / top / both,\r\nand the **Page size options** for the selector), and per-**Column** settings -\r\nexpand a column to set its **header**, **width**, **alignment**, **pin** (left /\r\nright), plus show / hide + reorder. There is no standalone \"Edit form\" block - the\r\ngrid owns editing end to end.\r\n\r\n### Export toolbar\r\n\r\n**Export toolbar** adds a button bar above the grid. Six options, in two groups:\r\n\r\n| Button | Runs through | Adds to the generated app |\r\n| ------ | ------------ | ------------------------- |\r\n| Export CSV / Export JSON / Copy | the free grid API | nothing |\r\n| Export Excel (.xlsx) | `@svgrid/enterprise` | `jszip` |\r\n| Export PDF | `@svgrid/enterprise` | `pdfmake` |\r\n| Print | `@svgrid/enterprise` | nothing |\r\n\r\nThe Excel export is real OOXML - typed number and date cells, styled headers, a\r\nfrozen header row - not a renamed CSV. PDF is paginated with a repeating header,\r\nand Print opens the browser's print dialog on a paginated layout.\r\n\r\nAll six export what the user currently sees: the visible columns, in their\r\ncurrent order, over the filtered and sorted rows. The optional dependencies are\r\ndeclared only for the buttons you switch on, so a CSV-only app installs neither.\r\nThe canvas preview runs the same code the generated app does, so you can try a\r\nreal export before generating.\r\n\r\n## The analytical + companion blocks\r\n\r\nBeyond the grid, every block is still bound to the `EntitySchema` - these are data\r\nviews, not generic widgets:\r\n\r\n| Block | What it renders | Inspector |\r\n| --- | --- | --- |\r\n| **Chart** | A chart (`SvSchemaChart`) - bar, pie, line, area, radar, funnel, waterfall, or treemap. | Group-by dimension, measure, reduce, type. |\r\n| **Pivot** | A full pivot table (`SvPivotDesigner`) the end user can re-pivot live. | Row + column dimensions (checkboxes), a measure, and its aggregate. |\r\n| **Dashboard** | A schema-driven KPI + chart board (`SvSchemaDashboard`). | - |\r\n| **KPI** | A single reduced metric tile. | Label, measure, reduce. |\r\n| **Gauge** | A radial gauge (`SvGauge`) of one reduced measure within a range - utilization, progress, scores. | Label, measure, reduce, min / max, unit. |\r\n| **Tree** | A hierarchical tree (`SvTree`) built from the entity's own rows via a self-referential parent. | A label field + a parent field (a row's link to its parent row). |\r\n| **Tabs** | A tabbed container (`SvTabs`) that **groups display blocks** into tabs - e.g. an Overview tab of KPIs + a Details tab with a chart. | Add / rename / remove tabs; per tab, add child blocks (charts, KPIs, gauges, pivots, trees). |\r\n| **Accordion** | A collapsible-sections container (like Tabs, stacked vertically). | Add / rename / remove sections; child blocks per section. |\r\n| **Master / detail** | A row that expands into a nested grid of related records. | Child entity + foreign key. |\r\n| **Board** | A kanban board of the entity's rows, one lane per value of a group-by field, with drag between lanes. | Group-by, card title / subtitle / badge fields, open-screen drill. |\r\n| **Calendar** | A month event-calendar: each row with a date lands on its day, labelled and optionally color-coded. | Date field, title field, color field, open-screen drill. |\r\n| **Detail** | A full record \"detail page\": header, metric row, field sections, and related-record tabs - the 360 view a row action or drill-through opens. | Title / subtitle / status / metric fields, sections, related child entities. |\r\n| **Form** | A standalone create / edit form for the entity. | Presentation (drawer / modal / inline). |\r\n| **Filter panel** | A faceted sidebar that **filters the screen's grid** - enum / boolean facets pick a value, text facets search. | Title + which fields become facets. |\r\n| **Record panel** | Shows the row **selected in the grid** - a read-only field list, or an inline edit form. | Editable on / off, and (read-only) which fields to show. |\r\n| **Lookup** | Marks a relation field as a searchable picker in the edit form. | The relation field. |\r\n| **UI component** | A component from the SvGrid UI kit dropped from the toolbox - entity-agnostic, works on freestanding pages too. Grouped as Actions, Inputs, Display, Feedback, Layout, and Navigation, and covering headings and prose (heading, text, link, quote, code, keyboard key, list) as well as controls, pickers, and date/time inputs. | The component's own props (extracted from the component's own types, with its JSDoc as the tooltip), plus data bindings. |\r\n\r\nThe **filter** and **record** panels wire to the grid on the same screen: the\r\nfilter panel calls the grid controller's `setFilter`, and clicking a grid row\r\npublishes it to the record panel. So a common layout is a **filter panel + grid +\r\nrecord panel** three-up - list, narrow, inspect - all generated for you.\r\n\r\nThe grid, chart, pivot, and master-detail blocks are **height-resizable** (drag\r\nthe block's bottom edge, or set **Layout > Height**), and the chosen height flows\r\ninto the generated app. The filter and record panels size to their content.\r\n\r\n### Conditional formatting\r\n\r\nA grid's inspector has a **Conditional formatting** section: add no-code rules\r\nthat style a cell by its value - pick a field, a comparison (`=`, `<`, `>`,\r\n`contains`, `is empty`, ...), a value, and a **text color / fill / bold**. Rules\r\nrender **live in the canvas** and compile to the grid's built-in\r\n`conditionalFormats` rule engine in the generated app (e.g. negative `mrr` red,\r\n`status = overdue` filled). It's the same engine you'd use by hand - the designer\r\njust authors the rules.\r\n\r\n### Navigation & row actions\r\n\r\nThe grid's **Navigation & actions** section wires flow between screens:\r\n**drill-through** (row click opens another screen, filtered to the clicked value)\r\nand **row action buttons** (Edit / Delete / Open). A chart can drill too. See\r\n[Navigation & row actions](./navigation.md) for the full picture.\r\n\r\n## Data sources (per entity)\r\n\r\nEach entity binds to **its own backend** - the designer is not limited to one\r\ndata source per app. In the inspector (no block selected), the **Data source**\r\nsection binds the screen's entity to:\r\n\r\n- **In-memory** - seeded sample rows, runs with no backend (the default).\r\n- **Local database (no setup)** - a real, persistent Postgres ([PGlite](https://pglite.dev))\r\n running in the browser and saved to IndexedDB, so rows survive reloads with zero\r\n backend. Same SQL as production - swap to a hosted **SQL** source later without\r\n touching the schema. See [Local database](./local-database.md).\r\n The builder is a draggable, resizable, maximizable panel, and you can open it for\r\n any entity straight from the **Data model** dialog's **Configure** button.\r\n\r\n- **REST API** - a request builder: **method**, **base URL**, **path** (path\r\n params auto-derive from `{tokens}`), and **Query / Path / Header** tabs. **Send**\r\n runs it live and shows a **response table** plus the real rows in the canvas\r\n grid; **Import fields** rewrites the entity's schema to match the response keys.\r\n An **API format** picker (Manual / Offset + Limit / DummyJSON / json-server)\r\n wires a wire-format adapter so the grid does **real server-side paging and sort**;\r\n Manual keeps the rows-path / total-path mapping for a single fetched page.\r\n- **Supabase** - **List tables** reads your project's tables so you pick one, and\r\n **Import schema** pulls its real columns, primary key, foreign keys, and enums\r\n into the entity; **Preview** shows live rows (works in the online designer, since\r\n Supabase is HTTP). The project **URL + anon key are a shared connection** you set\r\n once for every Supabase entity. A **Live updates (Realtime)** toggle emits a live\r\n subscription. **Generate app** emits `createClient` in `connections.ts` reading\r\n `PUBLIC_SUPABASE_URL` / `PUBLIC_SUPABASE_ANON_KEY` from `.env` (the key is never\r\n inlined) and adds `@supabase/supabase-js`; access is protected by your RLS\r\n policies.\r\n- **SQL** - paste a connection string (the dialect is auto-detected) or fill the\r\n guided form, set the **Schema** (Postgres search path). In the **local** designer\r\n (`npx @svgrid/studio designer`) **Preview data** runs a real `SELECT` and shows\r\n your actual rows on the canvas, and a missing `pg` / `mysql2` / ... driver is a\r\n **one-click install**. **Generate app** emits a connected\r\n `src/routes/api/<table>/+server.ts` (the dialect's driver, reading\r\n `DATABASE_URL`) and points the grid at it; the driver dep is added for you. In\r\n the **online** designer, bind the entity and generate - the app connects for real\r\n at runtime. To scaffold from an existing DB via CLI: `npx @svgrid/studio add\r\n <table> --db <dialect> --url …`.\r\n\r\n**Generate app** then emits the matching adapter per entity in `src/lib/data.ts`\r\n(`createRestDataSource` / `createSqlDataSource` / `createSupabaseDataSource` /\r\n`createInMemoryDataSource`). SQL and Supabase entities read their connection from a\r\ngenerated `src/lib/connections.ts` (or the `+server.ts` route) - the SQL driver and\r\nthe Supabase client are wired for you; the only manual step is setting the\r\nconnection in `.env` (the bundle ships a `.env.example`). See\r\n[Databases](./databases.md) and [REST API](./rest-api.md).\r\n\r\n## Import a CSV / spreadsheet\r\n\r\nThe fastest way to start from **your own data**: **Import CSV** in the top bar\r\ntakes a `.csv` file and turns it into a running screen. The designer parses the\r\nfile (quoted fields, embedded newlines, and CRLF included), **infers a type per\r\ncolumn** from its values (number, boolean, date, or text - thousands separators\r\nand `yes/no/true/false` are understood), ensures a **primary key** (it reuses an\r\n`id` column or synthesizes one), and adds an entity with a full CRUD screen,\r\n**seeded with the real rows**. Unsafe headers (`First Name`, `E-mail`) become safe\r\nfield keys and the note tells you what was renamed.\r\n\r\nImported rows ship **in-memory** by default (no dependencies), so the app runs\r\nimmediately. Switch that entity's **Data source** to **Local database** to make\r\nthe same imported rows **persist** across reloads - the seed carries over. This is\r\nall client-side: `csvToEntity(name, text)` is a pure function exported from\r\n`@svgrid/enterprise`, so the same import works in the CLI and your own tools.\r\n\r\n> **Large files:** the imported rows are stored as the entity's seed, so they are\r\n> embedded in `studio.config.json` when you **Save** the design. That is fine for\r\n> reference data and samples; for a large dataset, import a representative sample\r\n> and point the entity at a **database** (Local database / SQL) for the full data.\r\n\r\n## Pages and layout\r\n\r\n- **Pages** - each screen is a route. In the inspector's **Page** section, toggle\r\n **Show in navigation**, set a **nav label** and **nav order**, or start a page\r\n from the **Empty** template. Hidden pages stay routable but drop out of the nav.\r\n- **Render mode** - eligible screens (a single plain grid, or read-only block\r\n screens, on a memory / SQL source) can switch from the default client page to\r\n **SSR**: an idiomatic `+page.server.ts` with `load` + form actions. The rules\r\n are in [Code generation](./code-generation.md#render-mode-spa-or-ssr-per-screen).\r\n- **App layout** - the rail's **App layout** section themes the generated shell:\r\n **Sidebar** or **Top navigation**, a **brand** name, a **company logo**\r\n (uploaded - stored inline and shown in the nav in place of the brand text), a\r\n **footer**, and (for the sidebar) the **nav position** (left / right). This\r\n drives the generated `src/routes/+layout.svelte`. The generated shell is\r\n **responsive**: on phones the sidebar collapses to a hamburger drawer, the\r\n top-nav links scroll, and each screen's block grid stacks to one column.\r\n\r\n## Save, regenerate, round-trip\r\n\r\nThe `StudioProject` is the persisted design. **Save config** exports a\r\n`studio.config.json`; regenerate the app from it any time:\r\n\r\n```bash\r\nnpm create @svgrid/studio@latest my-app -- --project ./studio.config.json\r\n```\r\n\r\nOr programmatically:\r\n\r\n```ts\r\nimport { serializeProject, parseProject, emitStudioProject } from '@svgrid/enterprise'\r\n\r\nconst json = serializeProject(project) // save the design\r\nconst project2 = parseProject(json) // reopen it\r\nconst files = emitStudioProject(project2) // -> the app's source files\r\n```\r\n\r\n**The exported app carries its own design.** The downloaded zip includes a\r\n`studio.config.json` at its root. To keep editing the app visually after you've\r\nworked on it locally, open the designer and **Load** that file - entities,\r\nscreens, blocks, theme, RBAC, i18n, and now the logo all come back exactly as\r\ngenerated. Because the designer regenerates the files under `src/`, keep any\r\nhand-written code in **new** files/modules you import, so a re-generate never\r\noverwrites it (or use the CLI's `svgrid:managed` markers - see\r\n[Code generation](./code-generation.md)).\r\n\r\n## Generate the app\r\n\r\n**Generate app** emits `src/lib/schemas.ts`, `src/lib/data.ts` (the right adapter\r\nper entity, plus `src/lib/connections.ts` when any entity is SQL / Supabase-bound),\r\nand one `src/routes/<route>/+page.svelte` **per screen** that composes that\r\nscreen's blocks with their config - a grid (with your visible columns, in order) +\r\nedit modal, plus any charts / pivots / dashboard / KPI tiles, and filter / record\r\npanels wired to the grid, all bound to the data - and the\r\nnav layout (sidebar or top-nav) + home. The pages are self-contained (they use\r\n`@svgrid/grid` + `@svgrid/enterprise` directly), so the output runs as a standard\r\nSvelteKit app.\r\n\r\n```ts\r\nimport { emitStudioProject } from '@svgrid/enterprise'\r\nconst files = emitStudioProject(project) // [{ path, contents, description }, ...]\r\n```\r\n\r\n## See also\r\n\r\n- [Code behind (Code view)](./code-behind.md) - write TypeScript against a typed `ctx` (grid API, events, `ctx.grid.sortable = true`, lifecycle)\r\n- [Sample apps + bind your data](./samples.md) - start from a ready-made app, then point it at your database\r\n- [Launch the designer](./launch.md) - `npx @svgrid/studio designer` (auto-save + generate to a folder)\r\n- [Schema designer](./designer.md) - author a single entity\r\n- [Dashboards](./dashboards.md) Ā· [Databases](./databases.md) - the blocks + data sources\r\n- [CLI](./cli.md) / [Drizzle](./drizzle.md) / [Prisma](./prisma.md) - import a schema to design from\r\n"
2697
2718
  },
2698
2719
  {
2699
2720
  "slug": "enterprise/studio/audit-log",
@@ -2711,13 +2732,13 @@ export const docs = [
2711
2732
  "slug": "enterprise/studio/business-logic",
2712
2733
  "path": "docs/enterprise/studio/business-logic.md",
2713
2734
  "title": "Computed fields & hooks",
2714
- "markdown": "# Computed fields & hooks\r\n\r\nTwo ways to put business logic on an `EntitySchema`: **computed fields** (values\r\nderived from the row) and **hooks** (logic that runs when rows are created,\r\nupdated, or deleted). Both live on the schema, so they apply everywhere the\r\nschema does - grid, edit form, and generated code.\r\n\r\n![Computed fields and hooks attach to the EntitySchema and apply everywhere it does: the grid, the edit form, and the generated code.](/docs-media/studio-business-logic.svg)\r\n\r\n> **No-code, in the designer.** You don't have to write these by hand. In the\r\n> [visual designer](./app-designer.md), a field's editor has a **ʒ formula** box\r\n> (e.g. `qty * price`, `first + ' ' + last`) that compiles into `computed`, and a\r\n> **Business rules (validation)** section builds cross-field rules (`price ≄ qty`,\r\n> \"required\", min/max length, ...) with custom messages that compile into\r\n> `hooks.validate`. Bare field names resolve to the row. The sections below are\r\n> what that generates - and what you'd write directly for anything more complex.\r\n\r\n## Computed fields\r\n\r\nA computed field's value is derived from the row, never stored. Give the field a\r\n`computed` function:\r\n\r\n```ts\r\nimport type { EntitySchema } from '@svgrid/enterprise'\r\n\r\ntype Order = { id: string; qty: number; price: number; total: number }\r\n\r\nconst orderSchema: EntitySchema<Order> = {\r\n name: 'orders',\r\n idField: 'id',\r\n fields: [\r\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\r\n { field: 'qty', type: 'number', required: true, min: 1 },\r\n { field: 'price', type: 'number', required: true, min: 0 },\r\n // Derived from the row - read-only, never submitted.\r\n { field: 'total', type: 'number', label: 'Total ($)', computed: (r) => r.qty * r.price },\r\n ],\r\n}\r\n```\r\n\r\nA computed field is:\r\n\r\n- **read-only** in the grid and the edit form,\r\n- **live** in the form - it re-evaluates as you edit the fields it depends on,\r\n- **never sent** in a create / update payload (it's derived, not stored).\r\n\r\nTo make the grid show, sort, and filter the value, materialize it onto the rows\r\nwith `withEntityRules` (below) or `applyComputed(schema, row)`.\r\n\r\n> Computed columns sort / filter within the **fetched page** (they're\r\n> materialized client-side). For a value the database can sort across all pages,\r\n> use a real column instead.\r\n\r\n## Hooks\r\n\r\n`schema.hooks` run business logic at the mutation boundary. Before-hooks can\r\ntransform the payload or throw to reject; after-hooks are side effects; and\r\n`validate` does cross-field form validation.\r\n\r\n```ts\r\nconst orderSchema: EntitySchema<Order> = {\r\n name: 'orders',\r\n idField: 'id',\r\n fields: [ /* ... */ ],\r\n hooks: {\r\n // Transform the create payload (e.g. stamp a timestamp).\r\n beforeCreate: (values) => ({ ...values, createdAt: new Date().toISOString() }),\r\n // Cross-field validation: return { field: message } or null.\r\n validate: (values) => (values.qty <= 0 ? { qty: 'Quantity must be at least 1' } : null),\r\n // Side effects.\r\n afterCreate: (row) => console.log('created', row.id),\r\n afterUpdate: (row) => console.log('updated', row.id),\r\n beforeDelete: (id) => { /* throw to veto */ },\r\n },\r\n}\r\n```\r\n\r\nThe `validate` hook runs in two places, so a rule is written once:\r\n\r\n- the **edit form** merges its errors alongside the per-field ones (per-field\r\n wins), and\r\n- `withEntityRules` runs it again as a write guard.\r\n\r\n## Wiring it up: `withEntityRules`\r\n\r\n`withEntityRules(source, schema)` wraps any `ServerDataSource` so computed fields\r\nare materialized on read and hooks run on writes. It's transparent - pass the\r\nresult to `createServerDataSource` (or the grid) exactly as before:\r\n\r\n```ts\r\nimport { withEntityRules, createSqlDataSource } from '@svgrid/enterprise'\r\n\r\nconst source = withEntityRules(createSqlDataSource(/* ... */), orderSchema)\r\n```\r\n\r\n- `getRows` -> each row through `applyComputed`.\r\n- `createRow` / `updateRow` -> `validate` + `beforeCreate/Update` (may transform\r\n or throw), then `applyComputed` on the returned row, then `afterCreate/Update`.\r\n- `deleteRow` -> `beforeDelete` (throw to veto) then `afterDelete`.\r\n\r\nOptional write methods stay optional, and extra capabilities (`getAggregate`)\r\npass through. Because every method returns the same computed row shape, the\r\ncontroller's optimistic updates stay correct.\r\n\r\n## Server-enforced validation at the route\r\n\r\n`withEntityRules` runs your `hooks.validate`, but the **declarative** field\r\nconstraints (`required`, `min` / `max`, `minLength` / `maxLength`, `pattern`,\r\nemail / url) are data on the schema - and the API route can re-check them on\r\nevery write, independent of the client. Pass `validate: true` to\r\n`createKitHandlers`:\r\n\r\n```ts\r\nimport { createKitHandlers } from '@svgrid/enterprise'\r\n\r\nexport const { POST } = createKitHandlers({\r\n schema: orderSchema,\r\n source,\r\n validate: true, // reject bad writes with 422 { error, fieldErrors }\r\n})\r\n```\r\n\r\n- On **create**, the whole payload is validated; on **update**, only the fields\r\n present in the patch (a patch legitimately omits the rest).\r\n- A failure returns `422` with a `{ field: message }` map - the same shape the\r\n edit form uses, so a custom client can surface the errors inline.\r\n- It runs **before** the source is touched, so invalid data never reaches your\r\n database - even if a client skipped its own form validation or posted directly.\r\n\r\nPrefer your own logic? Pass a function instead of `true`:\r\n\r\n```ts\r\nvalidate: ({ action, values, event }) =>\r\n values.total < 0 ? { total: 'Total cannot be negative' } : null,\r\n```\r\n\r\n> **Studio generates this for you.** Every connected (`SQL` / Supabase) route the\r\n> app generator emits already includes `validate: true`, so a generated app is\r\n> server-validated out of the box. This pairs with [RBAC](./access-control.md),\r\n> which adds the `authorize` guard on the same route.\r\n\r\n## No-code triggers in the designer\r\n\r\nEverything above is code you write. The designer offers the same power as\r\n**Triggers** on an entity - no-code server-side rules that compile into the\r\ngenerated route's hooks. Six events, matching the hook points:\r\n`beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, `beforeDelete`,\r\n`afterDelete`. Each event holds a list of steps:\r\n\r\n| Step | What it does |\r\n| --- | --- |\r\n| **Set field** | Assign a value (a literal, another field, or an expression) - a transform before the write. |\r\n| **Require field** | Reject the write when a field is empty, with your message. |\r\n| **Reject when** | Reject the write when a condition holds - a cross-field guard. |\r\n| **Branch** | If / else over a condition, each side its own step list. |\r\n| **Code** | Escape hatch: a snippet for anything the steps cannot say. |\r\n\r\nBefore-hooks transform and validate; after-hooks are for side effects. The\r\nsteps are stored in the project model and enforced **on the server route** of\r\nSQL-bound entities - the same place `validate: true` runs - so they hold even\r\nagainst direct API calls.\r\n\r\n## A worked example\r\n\r\nComputed `subtotal` / `tax` / `total`, a cross-field guard, and a delete veto -\r\nall on one schema:\r\n\r\n```ts\r\ntype Invoice = {\r\n id: string; qty: number; rate: number; taxRate: number\r\n subtotal: number; tax: number; total: number\r\n status: 'draft' | 'sent' | 'paid'\r\n}\r\n\r\nconst invoiceSchema: EntitySchema<Invoice> = {\r\n name: 'invoices',\r\n idField: 'id',\r\n fields: [\r\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\r\n { field: 'qty', type: 'number', required: true, min: 1 },\r\n { field: 'rate', type: 'number', required: true, min: 0, label: 'Rate ($)' },\r\n { field: 'taxRate', type: 'number', min: 0, max: 1, defaultValue: 0.2 },\r\n { field: 'subtotal', type: 'number', computed: (r) => r.qty * r.rate },\r\n { field: 'tax', type: 'number', computed: (r) => r.qty * r.rate * r.taxRate },\r\n { field: 'total', type: 'number', label: 'Total ($)', computed: (r) => r.qty * r.rate * (1 + r.taxRate) },\r\n { field: 'status', type: 'enum', options: [\r\n { value: 'draft', label: 'Draft' }, { value: 'sent', label: 'Sent' }, { value: 'paid', label: 'Paid' },\r\n ] },\r\n ],\r\n hooks: {\r\n // cross-field guard, shown in the form and enforced on write\r\n validate: (v) => (v.taxRate != null && (v.taxRate < 0 || v.taxRate > 1)\r\n ? { taxRate: 'Tax rate must be between 0 and 1' } : null),\r\n // (id, values) - stamp an audit field on update\r\n beforeUpdate: (id, patch) => ({ ...patch, updatedAt: new Date().toISOString() }),\r\n // veto a delete by throwing\r\n beforeDelete: (id) => { /* if (await isPaid(id)) throw new Error('Paid invoices cannot be deleted') */ },\r\n },\r\n}\r\n```\r\n\r\nOnly `qty`, `rate`, `taxRate`, and `status` are ever submitted - `subtotal`,\r\n`tax`, and `total` are derived on every read. Wrap the source with\r\n`withEntityRules` and the exact same rules apply whether the source is in-memory\r\n(client-side) or SQL (inside the API route) - write the logic once, run it\r\neverywhere.\r\n\r\nLive demo: [Computed fields & hooks](https://svgrid.com/demos/199-studio-computed-hooks/).\r\n\r\n## See also\r\n\r\n- [Edit forms](./edit-forms.md) Ā· [Schema](./schema.md)\r\n- [Dashboards](./dashboards.md) - schema-driven KPI + chart views\r\n"
2735
+ "markdown": "# Computed fields & hooks\r\n\r\nTwo ways to put business logic on an `EntitySchema`: **computed fields** (values\r\nderived from the row) and **hooks** (logic that runs when rows are created,\r\nupdated, or deleted). Both live on the schema, so they apply everywhere the\r\nschema does - grid, edit form, and generated code.\r\n\r\n![Computed fields and hooks attach to the EntitySchema and apply everywhere it does: the grid, the edit form, and the generated code.](/docs-media/studio-business-logic.svg)\r\n\r\n> **No-code, in the designer.** You don't have to write these by hand. In the\r\n> [visual designer](./app-designer.md), a field's editor has a **ʒ formula** box\r\n> (e.g. `qty * price`, `first + ' ' + last`) that compiles into `computed`, and a\r\n> **Business rules (validation)** section builds cross-field rules (`price ≄ qty`,\r\n> \"required\", min/max length, ...) with custom messages that compile into\r\n> `hooks.validate`. Bare field names resolve to the row. The sections below are\r\n> what that generates - and what you'd write directly for anything more complex.\r\n\r\n## Computed fields\r\n\r\nA computed field's value is derived from the row, never stored. Give the field a\r\n`computed` function:\r\n\r\n```ts\r\nimport type { EntitySchema } from '@svgrid/enterprise'\r\n\r\ntype Order = { id: string; qty: number; price: number; total: number }\r\n\r\nconst orderSchema: EntitySchema<Order> = {\r\n name: 'orders',\r\n idField: 'id',\r\n fields: [\r\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\r\n { field: 'qty', type: 'number', required: true, min: 1 },\r\n { field: 'price', type: 'number', required: true, min: 0 },\r\n // Derived from the row - read-only, never submitted.\r\n { field: 'total', type: 'number', label: 'Total ($)', computed: (r) => r.qty * r.price },\r\n ],\r\n}\r\n```\r\n\r\nA computed field is:\r\n\r\n- **read-only** in the grid and the edit form,\r\n- **live** in the form - it re-evaluates as you edit the fields it depends on,\r\n- **never sent** in a create / update payload (it's derived, not stored).\r\n\r\nTo make the grid show, sort, and filter the value, materialize it onto the rows\r\nwith `withEntityRules` (below) or `applyComputed(schema, row)`.\r\n\r\n> Computed columns sort / filter within the **fetched page** (they're\r\n> materialized client-side). For a value the database can sort across all pages,\r\n> use a real column instead.\r\n\r\n## Hooks\r\n\r\n`schema.hooks` run business logic at the mutation boundary. Before-hooks can\r\ntransform the payload or throw to reject; after-hooks are side effects; and\r\n`validate` does cross-field form validation.\r\n\r\n```ts\r\nconst orderSchema: EntitySchema<Order> = {\r\n name: 'orders',\r\n idField: 'id',\r\n fields: [ /* ... */ ],\r\n hooks: {\r\n // Transform the create payload (e.g. stamp a timestamp).\r\n beforeCreate: (values) => ({ ...values, createdAt: new Date().toISOString() }),\r\n // Cross-field validation: return { field: message } or null.\r\n validate: (values) => (values.qty <= 0 ? { qty: 'Quantity must be at least 1' } : null),\r\n // Side effects.\r\n afterCreate: (row) => console.log('created', row.id),\r\n afterUpdate: (row) => console.log('updated', row.id),\r\n beforeDelete: (id) => { /* throw to veto */ },\r\n },\r\n}\r\n```\r\n\r\nThe `validate` hook runs in two places, so a rule is written once:\r\n\r\n- the **edit form** merges its errors alongside the per-field ones (per-field\r\n wins), and\r\n- `withEntityRules` runs it again as a write guard.\r\n\r\n## Wiring it up: `withEntityRules`\r\n\r\n`withEntityRules(source, schema)` wraps any `ServerDataSource` so computed fields\r\nare materialized on read and hooks run on writes. It's transparent - pass the\r\nresult to `createServerDataSource` (or the grid) exactly as before:\r\n\r\n```ts\r\nimport { withEntityRules, createSqlDataSource } from '@svgrid/enterprise'\r\n\r\nconst source = withEntityRules(createSqlDataSource(/* ... */), orderSchema)\r\n```\r\n\r\n- `getRows` -> each row through `applyComputed`.\r\n- `createRow` / `updateRow` -> `validate` + `beforeCreate/Update` (may transform\r\n or throw), then `applyComputed` on the returned row, then `afterCreate/Update`.\r\n- `deleteRow` -> `beforeDelete` (throw to veto) then `afterDelete`.\r\n\r\nOptional write methods stay optional, and extra capabilities (`getAggregate`)\r\npass through. Because every method returns the same computed row shape, the\r\ncontroller's optimistic updates stay correct.\r\n\r\n## Server-enforced validation at the route\r\n\r\n`withEntityRules` runs your `hooks.validate`, but the **declarative** field\r\nconstraints (`required`, `min` / `max`, `minLength` / `maxLength`, `pattern`,\r\nemail / url) are data on the schema - and the API route can re-check them on\r\nevery write, independent of the client. Pass `validate: true` to\r\n`createKitHandlers`:\r\n\r\n```ts\r\nimport { createKitHandlers } from '@svgrid/enterprise'\r\n\r\nexport const { POST } = createKitHandlers({\r\n schema: orderSchema,\r\n source,\r\n validate: true, // reject bad writes with 422 { error, fieldErrors }\r\n})\r\n```\r\n\r\n- On **create**, the whole payload is validated; on **update**, only the fields\r\n present in the patch (a patch legitimately omits the rest).\r\n- A failure returns `422` with a `{ field: message }` map - the same shape the\r\n edit form uses, so a custom client can surface the errors inline.\r\n- It runs **before** the source is touched, so invalid data never reaches your\r\n database - even if a client skipped its own form validation or posted directly.\r\n\r\nPrefer your own logic? Pass a function instead of `true`:\r\n\r\n```ts\r\nvalidate: ({ action, values, event }) =>\r\n values.total < 0 ? { total: 'Total cannot be negative' } : null,\r\n```\r\n\r\n> **Studio generates this for you.** Every connected (`SQL` / Supabase) route the\r\n> app generator emits already includes `validate: true`, so a generated app is\r\n> server-validated out of the box. This pairs with [RBAC](./access-control.md),\r\n> which adds the `authorize` guard on the same route.\r\n\r\n## No-code triggers in the designer\r\n\r\nEverything above is code you write. The designer offers the same power as\r\n**Triggers** on an entity - no-code server-side rules that compile into the\r\ngenerated route's hooks. Six events, matching the hook points:\r\n`beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, `beforeDelete`,\r\n`afterDelete`. Each event holds a list of steps:\r\n\r\n| Step | What it does |\r\n| --- | --- |\r\n| **Set field** | Assign a value (a literal, another field, or an expression) - a transform before the write. |\r\n| **Require field** | Reject the write when a field is empty, with your message. |\r\n| **Reject when** | Reject the write when a condition holds - a cross-field guard. |\r\n| **Branch** | If / else over a condition, each side its own step list. |\r\n| **Code** | Escape hatch: a snippet for anything the steps cannot say. |\r\n\r\nBefore-hooks transform and validate; after-hooks are for side effects. The\r\nsteps are stored in the project model and enforced **on the server route** of\r\nSQL-bound entities - the same place `validate: true` runs - so they hold even\r\nagainst direct API calls.\r\n\r\n## A worked example\r\n\r\nComputed `subtotal` / `tax` / `total`, a cross-field guard, and a delete veto -\r\nall on one schema:\r\n\r\n```ts\r\ntype Invoice = {\r\n id: string; qty: number; rate: number; taxRate: number\r\n subtotal: number; tax: number; total: number\r\n status: 'draft' | 'sent' | 'paid'\r\n}\r\n\r\nconst invoiceSchema: EntitySchema<Invoice> = {\r\n name: 'invoices',\r\n idField: 'id',\r\n fields: [\r\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\r\n { field: 'qty', type: 'number', required: true, min: 1 },\r\n { field: 'rate', type: 'number', required: true, min: 0, label: 'Rate ($)' },\r\n { field: 'taxRate', type: 'number', min: 0, max: 1, defaultValue: 0.2 },\r\n { field: 'subtotal', type: 'number', computed: (r) => r.qty * r.rate },\r\n { field: 'tax', type: 'number', computed: (r) => r.qty * r.rate * r.taxRate },\r\n { field: 'total', type: 'number', label: 'Total ($)', computed: (r) => r.qty * r.rate * (1 + r.taxRate) },\r\n { field: 'status', type: 'enum', options: [\r\n { value: 'draft', label: 'Draft' }, { value: 'sent', label: 'Sent' }, { value: 'paid', label: 'Paid' },\r\n ] },\r\n ],\r\n hooks: {\r\n // cross-field guard, shown in the form and enforced on write\r\n validate: (v) => (v.taxRate != null && (v.taxRate < 0 || v.taxRate > 1)\r\n ? { taxRate: 'Tax rate must be between 0 and 1' } : null),\r\n // (id, values) - stamp an audit field on update\r\n beforeUpdate: (id, patch) => ({ ...patch, updatedAt: new Date().toISOString() }),\r\n // veto a delete by throwing\r\n beforeDelete: (id) => { /* if (await isPaid(id)) throw new Error('Paid invoices cannot be deleted') */ },\r\n },\r\n}\r\n```\r\n\r\nOnly `qty`, `rate`, `taxRate`, and `status` are ever submitted - `subtotal`,\r\n`tax`, and `total` are derived on every read. Wrap the source with\r\n`withEntityRules` and the exact same rules apply whether the source is in-memory\r\n(client-side) or SQL (inside the API route) - write the logic once, run it\r\neverywhere.\r\n\r\nLive demo: [Computed fields & hooks](https://svgrid.com/demos/199-studio-computed-hooks/).\r\n\r\n## Scheduled jobs\r\n\r\nTriggers run when someone changes a row. For work that runs on a clock -\r\na nightly digest, a cleanup pass - add a **scheduled job**:\r\n\r\n```ts\r\nproject.jobs = [\r\n { id: 'daily-digest', name: 'Daily order digest', cron: '0 6 * * *',\r\n kind: 'email', entity: 'orders', to: 'ops@example.com' },\r\n { id: 'cleanup', name: 'Purge stale drafts', cron: '0 3 * * *',\r\n kind: 'code', code: \"await db.delete(drafts).where(/* ... */)\" },\r\n]\r\n```\r\n\r\nTwo kinds: `email` sends a summary of an entity (row count plus the newest\r\nrows) through the generated email layer, and `code` runs a body you write.\r\n\r\nThis generates two files - `src/lib/server/jobs.ts` (the handlers, keyed by\r\njob id) and `src/routes/api/cron/+server.ts` - plus the schedule config for\r\nyour deploy target: `vercel.json` crons on Vercel, a GitHub Actions schedule\r\neverywhere else.\r\n\r\nThree things to know:\r\n\r\n- **It runs on the server**, unlike `createScheduler` in `@svgrid/enterprise`,\r\n which only ticks while a browser tab is open.\r\n- **The endpoint is secret-guarded.** Set `CRON_SECRET` in the app's\r\n environment and send it as `Authorization: Bearer <secret>` (or\r\n `?secret=`). With no `CRON_SECRET` set the route refuses to run rather than\r\n leaving a public \"do work\" URL open. The Actions workflow stays inert until\r\n you add the `CRON_URL` and `CRON_SECRET` repository secrets, so CI is green\r\n before you configure it.\r\n- **One failing job does not stop the others.** Each handler is caught\r\n individually and the response reports per-job status.\r\n\r\nAn `email` job needs the [auth email layer](./auth.md) switched on; without it\r\nthe handler slot is still emitted with a warning, so the schedule is real and\r\nthe gap is visible rather than silently dropped. `enabled: false` keeps a\r\nhandler callable via `?job=<id>` while dropping it from the scheduled run.\r\n\r\n## See also\r\n\r\n- [Edit forms](./edit-forms.md) Ā· [Schema](./schema.md)\r\n- [Dashboards](./dashboards.md) - schema-driven KPI + chart views\r\n"
2715
2736
  },
2716
2737
  {
2717
2738
  "slug": "enterprise/studio/cli",
2718
2739
  "path": "docs/enterprise/studio/cli.md",
2719
2740
  "title": "The Studio CLI",
2720
- "markdown": "# The Studio CLI\r\n\r\n`@svgrid/studio` is the command-line generator. One command reads a schema or a\r\nlive database and writes a working CRUD screen into your SvelteKit app.\r\n\r\n![The Studio CLI reads a schema file or a live database, runs one generate command, and writes a working CRUD screen: a schema module, a +server.ts route, and a +page.svelte into your SvelteKit app.](/docs-media/studio-cli.svg)\r\n\r\n> **New here?** Start with [Getting started](./getting-started.md) for the full\r\n> step-by-step path (create an app, install, first screen). This page is the CLI\r\n> reference.\r\n\r\n```bash\r\nnpx @svgrid/studio add <name> [--from <schema> | --db <dialect> --url <conn>] [options]\r\n```\r\n\r\n## `designer`\r\n\r\nOpen the visual [app designer](./app-designer.md) in your browser, auto-saving\r\nto `studio.config.json` and generating the app to a folder. See\r\n[Launch the designer](./launch.md) for the full guide.\r\n\r\n```bash\r\nnpx @svgrid/studio designer [--config <path>] [--out <dir>] [--port <n>] [--no-open]\r\n```\r\n\r\n## `add`\r\n\r\nGenerates three files for an entity: the schema module, the API route, and the\r\npage.\r\n\r\n| Flag | Description |\r\n| --- | --- |\r\n| `--from <path>` | Introspect a schema file: a Drizzle `schema.ts` or a Prisma `schema.prisma` (auto-detected). |\r\n| `--db <dialect>` | Connect to a live database: `postgres` \\| `supabase` \\| `mysql` \\| `mssql` \\| `sqlite`. |\r\n| `--url <conn>` | Connection string (or file path for SQLite) for `--db`. |\r\n| `--all` | Scaffold a screen for **every** table/model - works with `--from` or `--db`. |\r\n| `--table <name>` | Which table/model to use (defaults to `<name>`). |\r\n| `--sql` | Emit a `createSqlDataSource` with an `execute()` stub instead of a live driver. |\r\n| `--route <seg>` | Route segment (default: `<name>` / table name). |\r\n| `--api <path>` | API route path (default: `/api/<route>`). |\r\n| `-h`, `--help` | Show help. |\r\n\r\n## Examples\r\n\r\n```bash\r\n# from a live database\r\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\r\n\r\n# every table in the database\r\nnpx @svgrid/studio add --all --db mysql --url \"$DATABASE_URL\"\r\n\r\n# from a Drizzle schema file, a specific table, custom route\r\nnpx @svgrid/studio add orders --from src/lib/db/schema.ts --table orders --route sales/orders\r\n\r\n# every model in a Prisma schema (relations become lookups)\r\nnpx @svgrid/studio add --all --from prisma/schema.prisma\r\n\r\n# SQLite file\r\nnpx @svgrid/studio add todos --db sqlite --url ./data.db\r\n```\r\n\r\n## What it writes\r\n\r\nFor `add customers`:\r\n\r\n```\r\nsrc/lib/customers.schema.ts # EntitySchema + row type\r\nsrc/routes/api/customers/+server.ts # API route (createKitHandlers + a data source)\r\nsrc/routes/customers/+page.svelte # the screen: grid + edit panel\r\n```\r\n\r\nThen `npm run dev` and open `/customers`.\r\n\r\n## A whole app with `--all`\r\n\r\n`--all` scaffolds **every** table/model, plus an app shell that ties them\r\ntogether. It works from a live database (`--db`) **or** a schema file (`--from`\r\na Drizzle `schema.ts` or Prisma `schema.prisma`):\r\n\r\n```\r\nsrc/routes/+layout.svelte # nav sidebar linking every entity screen\r\nsrc/routes/+page.svelte # home page: a card per entity\r\nsrc/routes/<entity>/... # one screen per table/model (as above)\r\n```\r\n\r\nForeign keys are resolved across the whole set - a Drizzle `.references()` or a\r\nPrisma `@relation` becomes a searchable lookup - so each relation points at the\r\nright related screen and every `/api/<entity>` route it needs exists. The same\r\noutput is available programmatically as\r\n[`scaffoldApp(schemas, options)`](./code-generation.md) from `@svgrid/enterprise/studio`,\r\nfed by `introspectDrizzleAll` / `introspectPrismaAll`.\r\n\r\n## The other commands\r\n\r\n`designer` and `add` are the daily drivers; the binary has four more:\r\n\r\n| Command | What it does |\r\n| --- | --- |\r\n| `svgrid-studio dev` | The designer and the **running generated app side by side** - the designer writes real files into the app folder and Vite hot-reloads them. `--app-port <n>` sets the app's port. |\r\n| `svgrid-studio openapi <file\\|url>` | Import an OpenAPI (JSON) spec: paths + schemas become entities with REST sources in `studio.config.json`. See [REST & custom APIs](./rest-api.md). |\r\n| `svgrid-studio eject [--fragment]` | Write the full app (or, with `--fragment`, a drop-in set of files for an existing app) from `studio.config.json` without opening the designer. |\r\n| `svgrid-studio deploy [--target <p>] [--dry-run]` | Build, then deploy through the provider's own CLI. Target: `vercel` \\| `netlify` \\| `cloudflare` \\| `node`, resolved from the flag, the config, or `svelte.config.js`. `--dry-run` prints the commands. |\r\n\r\nUseful `designer` flags beyond the basics: `--template crm|ecommerce|projects|support`\r\nopens a [sample app](./samples.md) directly, and `--ai` enables the built-in\r\ncopilot (reads `ANTHROPIC_API_KEY` from the environment).\r\n\r\n## Safe regeneration\r\n\r\nEvery generated file wraps its body in `svgrid:managed` markers. Re-running\r\n`add` replaces **only** the managed region and preserves everything you wrote\r\noutside it - so you can regenerate after a schema change without losing\r\ncustomizations. See [Code generation](./code-generation.md).\r\n\r\n## Verification\r\n\r\nAfter writing files, run your project's own check to confirm they compile:\r\n\r\n```bash\r\nnpx svelte-check\r\n```\r\n\r\n## Requirements\r\n\r\n`@svgrid/grid` and `@svgrid/enterprise` in your project, plus the driver for your\r\ndatabase (`pg` / `mysql2` / `mssql` / `better-sqlite3`) when using `--db`. Studio\r\nis part of the [Enterprise license](../licensing.md) (soft-gate).\r\n\r\n## See also\r\n\r\n- [Databases](./databases.md) Ā· [Drizzle schema](./drizzle.md)\r\n- [AI generation](./ai-generation.md) - the same engine, driven by an AI agent\r\n- [Visual app designer](./app-designer.md) - the same engine, driven by a UI\r\n"
2741
+ "markdown": "# The Studio CLI\r\n\r\n`@svgrid/studio` is the command-line generator. One command reads a schema or a\r\nlive database and writes a working CRUD screen into your SvelteKit app.\r\n\r\n![The Studio CLI reads a schema file or a live database, runs one generate command, and writes a working CRUD screen: a schema module, a +server.ts route, and a +page.svelte into your SvelteKit app.](/docs-media/studio-cli.svg)\r\n\r\n> **New here?** Start with [Getting started](./getting-started.md) for the full\r\n> step-by-step path (create an app, install, first screen). This page is the CLI\r\n> reference.\r\n\r\n```bash\r\nnpx @svgrid/studio add <name> [--from <schema> | --db <dialect> --url <conn>] [options]\r\n```\r\n\r\n## `init`\r\n\r\nThe guided path: answer a few questions and get a working app. This is also what\r\nruns when you type `npx @svgrid/studio` with no arguments in a terminal.\r\n\r\n```bash\r\nnpx @svgrid/studio init [--db <dialect> --url <conn>] [--dataset <id>] [--out <dir>]\r\n```\r\n\r\nIt asks where the data comes from, which tables to include, which pages each\r\ntable gets, and which theme to use - then writes a runnable SvelteKit app plus a\r\n`studio.config.json` you can reopen in the [designer](./app-designer.md).\r\n\r\nEvery table you pick gets a **list** screen (searchable grid with status pills\r\nand totals), a **manage** screen (grid + editable record panel), and - when other\r\ntables relate to it - a **record page** with a tab per related collection. The\r\napp leads with an overview dashboard over its most-referenced table.\r\n\r\n| Flag | Description |\r\n| --- | --- |\r\n| `--db <dialect>` | Skip the source question and read a live database: `postgres` \\| `supabase` \\| `mysql` \\| `mssql` \\| `sqlite`. The driver is installed for you. |\r\n| `--url <conn>` | Connection string (or file path for SQLite) for `--db`. |\r\n| `--dataset <id>` | Start from sample data: `customers-orders`, `products-categories`, `projects-tasks`, `employees-departments`, `tickets-accounts`. |\r\n| `--title <name>` | App name. |\r\n| `--out <dir>` | Folder to write the app into (default: `.`). |\r\n| `--theme <id>` | Design-system preset (see [Theming](./theming.md)). |\r\n| `--dark` | Start in dark mode. |\r\n| `-y`, `--yes` | Take every default and ask nothing - useful in scripts and CI. |\r\n\r\n```bash\r\n# guided, from your own database\r\nnpx @svgrid/studio init --db postgres --url $DATABASE_URL --out my-app\r\n\r\n# no questions at all: a seeded sample app\r\nnpx @svgrid/studio init --yes --dataset projects-tasks --out demo\r\n```\r\n\r\nThe designer's **New app** button runs the same flow visually, and produces the\r\nsame project - pick whichever suits you.\r\n\r\n## `designer`\r\n\r\nOpen the visual [app designer](./app-designer.md) in your browser, auto-saving\r\nto `studio.config.json` and generating the app to a folder. See\r\n[Launch the designer](./launch.md) for the full guide.\r\n\r\n```bash\r\nnpx @svgrid/studio designer [--config <path>] [--out <dir>] [--port <n>] [--no-open]\r\n```\r\n\r\n## `add`\r\n\r\nGenerates three files for an entity: the schema module, the API route, and the\r\npage.\r\n\r\n| Flag | Description |\r\n| --- | --- |\r\n| `--from <path>` | Introspect a schema file: a Drizzle `schema.ts` or a Prisma `schema.prisma` (auto-detected). |\r\n| `--db <dialect>` | Connect to a live database: `postgres` \\| `supabase` \\| `mysql` \\| `mssql` \\| `sqlite`. |\r\n| `--url <conn>` | Connection string (or file path for SQLite) for `--db`. |\r\n| `--all` | Scaffold a screen for **every** table/model - works with `--from` or `--db`. |\r\n| `--table <name>` | Which table/model to use (defaults to `<name>`). |\r\n| `--sql` | Emit a `createSqlDataSource` with an `execute()` stub instead of a live driver. |\r\n| `--route <seg>` | Route segment (default: `<name>` / table name). |\r\n| `--api <path>` | API route path (default: `/api/<route>`). |\r\n| `-h`, `--help` | Show help. |\r\n\r\n## Examples\r\n\r\n```bash\r\n# from a live database\r\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\r\n\r\n# every table in the database\r\nnpx @svgrid/studio add --all --db mysql --url \"$DATABASE_URL\"\r\n\r\n# from a Drizzle schema file, a specific table, custom route\r\nnpx @svgrid/studio add orders --from src/lib/db/schema.ts --table orders --route sales/orders\r\n\r\n# every model in a Prisma schema (relations become lookups)\r\nnpx @svgrid/studio add --all --from prisma/schema.prisma\r\n\r\n# SQLite file\r\nnpx @svgrid/studio add todos --db sqlite --url ./data.db\r\n```\r\n\r\n## What it writes\r\n\r\nFor `add customers`:\r\n\r\n```\r\nsrc/lib/customers.schema.ts # EntitySchema + row type\r\nsrc/routes/api/customers/+server.ts # API route (createKitHandlers + a data source)\r\nsrc/routes/customers/+page.svelte # the screen: grid + edit panel\r\n```\r\n\r\nThen `npm run dev` and open `/customers`.\r\n\r\n## A whole app with `--all`\r\n\r\n`--all` scaffolds **every** table/model, plus an app shell that ties them\r\ntogether. It works from a live database (`--db`) **or** a schema file (`--from`\r\na Drizzle `schema.ts` or Prisma `schema.prisma`):\r\n\r\n```\r\nsrc/routes/+layout.svelte # nav sidebar linking every entity screen\r\nsrc/routes/+page.svelte # home page: a card per entity\r\nsrc/routes/<entity>/... # one screen per table/model (as above)\r\n```\r\n\r\nForeign keys are resolved across the whole set - a Drizzle `.references()` or a\r\nPrisma `@relation` becomes a searchable lookup - so each relation points at the\r\nright related screen and every `/api/<entity>` route it needs exists. The same\r\noutput is available programmatically as\r\n[`scaffoldApp(schemas, options)`](./code-generation.md) from `@svgrid/enterprise/studio`,\r\nfed by `introspectDrizzleAll` / `introspectPrismaAll`.\r\n\r\n## The other commands\r\n\r\n`designer` and `add` are the daily drivers; the binary has four more:\r\n\r\n| Command | What it does |\r\n| --- | --- |\r\n| `svgrid-studio dev` | The designer and the **running generated app side by side** - the designer writes real files into the app folder and Vite hot-reloads them. `--app-port <n>` sets the app's port. |\r\n| `svgrid-studio openapi <file\\|url>` | Import an OpenAPI (JSON) spec: paths + schemas become entities with REST sources in `studio.config.json`. See [REST & custom APIs](./rest-api.md). |\r\n| `svgrid-studio eject [--fragment]` | Write the full app (or, with `--fragment`, a drop-in set of files for an existing app) from `studio.config.json` without opening the designer. |\r\n| `svgrid-studio deploy [--target <p>] [--dry-run]` | Build, then deploy through the provider's own CLI. Target: `vercel` \\| `netlify` \\| `cloudflare` \\| `node`, resolved from the flag, the config, or `svelte.config.js`. `--dry-run` prints the commands. |\r\n\r\nUseful `designer` flags beyond the basics: `--template crm|ecommerce|projects|support`\r\nopens a [sample app](./samples.md) directly, and `--ai` enables the built-in\r\ncopilot (reads `ANTHROPIC_API_KEY` from the environment).\r\n\r\n## Safe regeneration\r\n\r\nEvery generated file wraps its body in `svgrid:managed` markers. Re-running\r\n`add` replaces **only** the managed region and preserves everything you wrote\r\noutside it - so you can regenerate after a schema change without losing\r\ncustomizations. See [Code generation](./code-generation.md).\r\n\r\n## Verification\r\n\r\nAfter writing files, run your project's own check to confirm they compile:\r\n\r\n```bash\r\nnpx svelte-check\r\n```\r\n\r\n## Requirements\r\n\r\n`@svgrid/grid` and `@svgrid/enterprise` in your project, plus the driver for your\r\ndatabase (`pg` / `mysql2` / `mssql` / `better-sqlite3`) when using `--db`. Studio\r\nis part of the [Enterprise license](../licensing.md) (soft-gate).\r\n\r\n## See also\r\n\r\n- [Databases](./databases.md) Ā· [Drizzle schema](./drizzle.md)\r\n- [AI generation](./ai-generation.md) - the same engine, driven by an AI agent\r\n- [Visual app designer](./app-designer.md) - the same engine, driven by a UI\r\n"
2721
2742
  },
2722
2743
  {
2723
2744
  "slug": "enterprise/studio/code-behind",
@@ -2789,7 +2810,7 @@ export const docs = [
2789
2810
  "slug": "enterprise/studio/getting-started",
2790
2811
  "path": "docs/enterprise/studio/getting-started.md",
2791
2812
  "title": "Getting started",
2792
- "markdown": "# Getting started\r\n\r\nThis is the gentlest path into SvGrid Studio. By the end you will have a real,\r\nworking **Customers** screen - a grid you can sort, filter, and page, with a\r\ncreate / edit form and delete - running on your machine. No prior experience\r\nwith SvGrid is assumed.\r\n\r\nIf you would rather just click around first, open a live demo - no install\r\nneeded:\r\n\r\n- **[Studio live SQL](https://svgrid.com/demos/193-studio-live-sql/)** - the whole stack in the browser, backed by real Postgres via PGlite\r\n- **[Live SQL](https://svgrid.com/demos/193-studio-live-sql/)** - a real Postgres in the browser (PGlite)\r\n- **[Supabase](https://svgrid.com/demos/194-studio-supabase/)** - connect your own hosted Postgres\r\n\r\n**Choose your tutorial.** This page needs no database and teaches the whole\r\nshape; the one-page tutorials build the same screen against a real backend.\r\nThey all end in the same place, so pick by what you have:\r\n\r\n| You have | Follow | What it adds |\r\n| --- | --- | --- |\r\n| Nothing yet | this page | the full path: install, schema, generate, run, change |\r\n| A Postgres connection string | [Postgres CRUD grid](./postgres-grid.md) | a server route querying your database |\r\n| A Supabase project | [Supabase CRUD grid](./supabase-grid.md) | browser client, keys, Row-Level Security |\r\n| An HTTP / JSON API | [REST CRUD grid](./rest-grid.md) | the REST adapter, no server route at all |\r\n| An afternoon | [Build a CRM](./tutorial-crm.md) | multi-entity: relations, master-detail, a real DB |\r\n\r\n---\r\n\r\n## Fastest path - a downloadable, ready-to-run example\r\n\r\nRather have a working project on your machine than type code into a blank\r\nfile? One command scaffolds a complete SvelteKit app with everything already\r\nwired up:\r\n\r\n```bash\r\nnpm create @svgrid/studio@latest my-app\r\n```\r\n\r\nYou'll be asked to pick a **theme** - one of `@svgrid/grid`'s 19 built-in\r\npresets (shadcn, Tailwind, Material, Excel, Fluent, and more) - and whether to\r\nstart in **light or dark** mode. Scripting this instead? Both are flags:\r\n\r\n```bash\r\nnpm create @svgrid/studio@latest my-app -- --theme material --dark\r\n```\r\n\r\nThen:\r\n\r\n```bash\r\ncd my-app\r\nnpm install\r\nnpm run dev\r\n```\r\n\r\nOpen `http://localhost:5173`. You get:\r\n\r\n- A **nav shell** and a home page (`src/routes/+layout.svelte`).\r\n- Two linked entities - **Customers** and **Orders** - each a full grid +\r\n modal create/edit/delete screen. Orders has a searchable lookup back to\r\n Customers, so you can see how relations work.\r\n- **Seeded in-memory data** - nothing to install or configure, no database.\r\n- The **theme and mode you picked**, applied to the whole app - not just the grid.\r\n\r\nIt is a real project, not a read-only demo - edit it, add fields, connect a\r\ndatabase, deploy it. A few places to start:\r\n\r\n| Want to... | Edit |\r\n| --- | --- |\r\n| Add or change a field | `src/lib/schemas.ts` - the grid and the form update together |\r\n| See how a screen is built | `src/lib/EntityScreen.svelte` - the reusable grid + modal CRUD screen every route uses |\r\n| Connect a real database | `src/lib/data.ts` - swap `createInMemoryDataSource` for `createSqlDataSource` / `createSupabaseDataSource` (see [Databases](./databases.md)) |\r\n| Add another screen from a live table | `npx @svgrid/studio add invoices --db postgres --url \"$DATABASE_URL\"` |\r\n\r\nThe template's own `README.md` covers the same ground once you're in the\r\nproject. Prefer to see each piece built up by hand instead, or add a screen to\r\nan *existing* app rather than a fresh one? Continue below.\r\n\r\n---\r\n\r\n## Three ways to build - pick yours\r\n\r\n![Build a data app in four no-code steps: open the designer, start from a sample or your database, arrange it visually, then generate the app.](/docs-media/studio-nocode-steps.svg)\r\n\r\nThis is the visual designer - a screens list on the left, your data previewed\r\nlive in the middle, and simple property panels on the right. You point, click, and\r\npress **Generate**:\r\n\r\n![The visual app designer with a customer grid previewed live and a properties panel for the screen and its fields.](/docs-media/studio-app-designer.png)\r\n\r\n- **Visual designer (no code).** You never write code - you point, click, and\r\n preview, then press one button to generate the finished app. Try it\r\n immediately, no install, at **[svgrid.com/studio](https://svgrid.com/studio)** -\r\n or run it locally with **[Launch the designer](./launch.md)**, which\r\n auto-saves to disk and generates straight into a folder. The\r\n [sample apps](./samples.md) let you open a complete, realistic app in one\r\n click and point it at your own data.\r\n- **CLI.** One deterministic command per screen: `npx @svgrid/studio add ...`\r\n introspects your table or schema and writes the files. No AI involved. This\r\n page uses the CLI from Step 3 on - continue below.\r\n- **AI via MCP.** With [`@svgrid/mcp`](./ai-generation.md) configured, ask your\r\n coding agent to build the screen; it introspects, scaffolds, and\r\n compile-verifies through the same core the CLI uses.\r\n\r\nAll three produce the **same generated code** - pick whichever fits how you\r\nwork, and switch freely later.\r\n\r\n---\r\n\r\n## What you need\r\n\r\n- **[Node.js](https://nodejs.org) 18 or newer.** Check with `node -v` in a\r\n terminal. If that errors, install Node first.\r\n- **A terminal** and a code editor (VS Code is fine).\r\n- **A SvelteKit app.** Don't have one? Create one in 30 seconds:\r\n\r\n ```bash\r\n npx sv create my-app # pick \"SvelteKit minimal\", TypeScript: yes\r\n cd my-app\r\n npm install\r\n ```\r\n\r\nYou do **not** need a database to start - the first screen below runs on\r\nin-memory data. You can point it at PostgreSQL, Supabase, MySQL, and others\r\nlater without changing the UI.\r\n\r\n---\r\n\r\n## Step 1 - Install\r\n\r\nInside your app folder:\r\n\r\n```bash\r\nnpm i @svgrid/grid @svgrid/enterprise\r\n```\r\n\r\n- `@svgrid/grid` is the grid itself.\r\n- `@svgrid/enterprise` adds Studio: the schema, the edit form, and the data-source\r\n helpers. It is **soft-gate only** - everything runs unlicensed, it just nudges.\r\n See [licensing](../licensing.md).\r\n\r\n---\r\n\r\n## Step 2 - Describe your data once\r\n\r\nThe generator needs one description of your table. If you have a live database\r\nit can introspect it directly (Step 3 shows that variant). Here we stay\r\ndatabase-free: describe the table in a small Drizzle schema file, which the\r\ngenerator **reads as text** - it never connects to anything.\r\n\r\n```bash\r\nnpm i -D drizzle-orm\r\n```\r\n\r\n(`drizzle-orm` is only there so the schema file type-checks; nothing runs\r\nagainst a database. It is also the natural next step when you do add one.)\r\n\r\nCreate `src/lib/db/schema.ts`:\r\n\r\n```ts\r\nimport { pgTable, text, integer, boolean } from 'drizzle-orm/pg-core'\r\n\r\nexport const customers = pgTable('customers', {\r\n id: text('id').primaryKey(),\r\n name: text('name').notNull(),\r\n email: text('email').notNull(),\r\n tier: text('tier').notNull().default('free'),\r\n mrr: integer('mrr'),\r\n active: boolean('active'),\r\n})\r\n```\r\n\r\nA Prisma `schema.prisma` works the same way. Prefer to write Studio's own\r\nmodel - the `EntitySchema` - by hand instead? That is the\r\n[appendix](#appendix-wire-it-by-hand-no-generator) at the bottom of this page.\r\n\r\n---\r\n\r\n## Step 3 - Generate the screen\r\n\r\nOne command:\r\n\r\n```bash\r\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\r\n```\r\n\r\nHave a live database instead? Same command, different source - no schema file\r\nneeded:\r\n\r\n```bash\r\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\r\n```\r\n\r\nEither way it writes **three files**, and the screen is done. A quick tour of\r\nwhat you now own:\r\n\r\n**1. `src/lib/customers.schema.ts` - the model.** The generator turned your\r\ntable into an `EntitySchema` - the single object that drives the grid columns,\r\nthe form fields, and validation:\r\n\r\n```ts\r\nexport type CustomersRow = {\r\n id: string\r\n name: string\r\n email: string\r\n tier: string\r\n mrr: number | null\r\n active: boolean | null\r\n}\r\n\r\nexport const customersSchema: EntitySchema<CustomersRow> = {\r\n name: 'customers',\r\n idField: 'id',\r\n fields: [\r\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\r\n { field: 'name', type: 'text', required: true },\r\n { field: 'email', type: 'text', required: true },\r\n // ...\r\n ],\r\n}\r\n```\r\n\r\nEvery field option (labels, enum choices, min/max, regex, custom validators) is\r\nexplained in [The EntitySchema](./schema.md).\r\n\r\n**2. `src/routes/api/customers/+server.ts` - the API route.** A\r\n`ServerDataSource` (read + create + update + delete) exposed over one SvelteKit\r\nendpoint. With `--from` it starts in-memory so it runs immediately; with `--db`\r\nit is already wired to your database:\r\n\r\n```ts\r\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\r\n\r\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\r\n```\r\n\r\nSwapping in a real database later means replacing that one `source` line - the\r\npage never changes. See [Databases](./databases.md).\r\n\r\n**3. `src/routes/customers/+page.svelte` - the screen.** The grid with\r\nserver-side sort, filter, global search, a native pagination footer,\r\nmulti-select delete with optimistic updates, and a modal create / edit form -\r\nall reading through the API route:\r\n\r\n```ts\r\nconst source = createKitDataSource<CustomersRow>({ endpoint: '/api/customers' })\r\nconst columns = schemaToColumns(customersSchema)\r\nconst controller = createServerDataSource<CustomersRow>(source, {\r\n pageSize: 25, optimistic: true,\r\n getRowId: (r) => String(r.id),\r\n onChange: (s) => (state = s),\r\n})\r\n```\r\n\r\nEach file wraps its generated body in `// svgrid:managed:start` /\r\n`// svgrid:managed:end` markers. Everything you write **outside** the markers\r\nis yours; re-running `add` only rewrites what is inside. That is what makes\r\nStep 5 safe.\r\n\r\n> One thing the generated screen inherits from your page: the **font**. A bare\r\n> `npx sv create` app sets no CSS at all, so add a\r\n> `body { font-family: system-ui, sans-serif }` rule (or a\r\n> [`--sg-font`](./theming.md) token) once, or the page renders in the\r\n> browser's default serif. Borders, backgrounds, and hover states the grid\r\n> themes itself.\r\n\r\n---\r\n\r\n## Step 4 - Run it\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\nOpen the URL it prints (usually `http://localhost:5173`) and go to\r\n**`/customers`**. With `--from` the grid starts empty (in-memory source, no\r\nseed) - click **New** and add two or three customers, then try the screen:\r\n\r\n- Click a **column header** to sort.\r\n- Type in the **filter row** under a header to filter (it stays focused as you type).\r\n- Click a **row** to edit. The form validates as you type - clear a required\r\n field and watch it complain.\r\n- Select rows with the checkboxes and **Delete** them - the grid updates\r\n instantly and rolls back if the server says no.\r\n- Page through with the **native pager** at the bottom.\r\n\r\n![A CRUD screen generated by SvGrid Studio: sortable, filterable grid with a native pager, over a live data source.](/docs-media/studio-crud.png)\r\n\r\n---\r\n\r\n## Step 5 - Change something\r\n\r\nBecause the schema drives everything, changes are one edit. Add a column to\r\n`src/lib/db/schema.ts`:\r\n\r\n```ts\r\ncountry: text('country'),\r\n```\r\n\r\nThen re-run the exact same command:\r\n\r\n```bash\r\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\r\n```\r\n\r\nThe managed regions are regenerated: the grid gets a **Country** column and the\r\nedit form gets a **Country** input. Anything you wrote outside the\r\n`svgrid:managed` markers - extra buttons, styles, handlers - is untouched.\r\nThat round trip (change schema, re-run, keep your code) is the everyday\r\nworkflow; [Code generation](./code-generation.md) explains the rules.\r\n\r\nPrefer not to re-run the generator? Editing the generated\r\n`customers.schema.ts` directly works too - grid and form update together from\r\nthe one schema object.\r\n\r\n---\r\n\r\n## Where to go next\r\n\r\nYou have the whole shape now. The usual next steps:\r\n\r\n- **Understand the model** - [Concepts](./concepts.md) walks the pipeline\r\n (schema, screens, data source, codegen) once and defines every Studio term.\r\n- **Connect a real database** - re-run `add` with `--db` and a connection\r\n string, or swap the one `source` line in the API route; the page does not\r\n change. For Supabase, follow the one-page\r\n **[Supabase CRUD grid tutorial](./supabase-grid.md)**; for SQL, see\r\n [Databases](./databases.md).\r\n- **Design visually** - `npx @svgrid/studio designer` opens the full app builder:\r\n compose screens across entities, bind data, and click *Generate app*. See the\r\n [Visual app designer](./app-designer.md). (To embed a single-entity schema editor\r\n in your own app, see the [Schema designer](./designer.md).)\r\n- **Build a full app** - the [Build a CRM tutorial](./tutorial-crm.md) wires up\r\n companies, contacts, and deals with relations and master-detail.\r\n\r\n---\r\n\r\n## Appendix: wire it by hand (no generator)\r\n\r\nEverything the generator wrote in Step 3 can be built up by hand - useful when\r\nyou want to see exactly how the pieces fit, or to embed a Studio screen in an\r\nunusual spot. Two files replace the three generated ones (no API route: here\r\nthe data source lives in the page itself).\r\n\r\nFirst, the `EntitySchema` - Studio's own model, the object the generator\r\nderived from your Drizzle file. Create `src/lib/customers.ts`:\r\n\r\n```ts\r\nimport type { EntitySchema } from '@svgrid/enterprise'\r\n\r\nexport type Customer = {\r\n id: string\r\n name: string\r\n email: string\r\n tier: 'free' | 'pro' | 'enterprise'\r\n mrr: number\r\n active: boolean\r\n}\r\n\r\nexport const customersSchema: EntitySchema<Customer> = {\r\n name: 'customers',\r\n label: 'Customer',\r\n idField: 'id',\r\n fields: [\r\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\r\n { field: 'name', type: 'text', required: true, minLength: 2 },\r\n { field: 'email', type: 'text', label: 'Email', required: true, format: 'email' },\r\n { field: 'tier', type: 'enum', options: [\r\n { value: 'free', label: 'Free' },\r\n { value: 'pro', label: 'Pro' },\r\n { value: 'enterprise', label: 'Enterprise' },\r\n ] },\r\n { field: 'mrr', type: 'number', label: 'MRR ($)', min: 0 },\r\n { field: 'active', type: 'boolean' },\r\n ],\r\n}\r\n```\r\n\r\nThen the page. `createInMemoryDataSource` provides the `ServerDataSource`\r\ncontract over a plain array, `createServerDataSource` runs sort / filter /\r\npage / CRUD against it, and the grid + edit panel render it. Create\r\n`src/routes/customers/+page.svelte`:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'\r\n import { SvGridEditPanel, createInMemoryDataSource, schemaToColumns } from '@svgrid/enterprise'\r\n import { customersSchema, type Customer } from '$lib/customers'\r\n\r\n const seed: Customer[] = [\r\n { id: 'c1', name: 'Ada Lovelace', email: 'ada@analytic.io', tier: 'enterprise', mrr: 1200, active: true },\r\n { id: 'c2', name: 'Alan Turing', email: 'alan@bletchley.uk', tier: 'pro', mrr: 240, active: true },\r\n { id: 'c3', name: 'Grace Hopper', email: 'grace@navy.mil', tier: 'enterprise', mrr: 980, active: true },\r\n ]\r\n\r\n const columns = schemaToColumns(customersSchema)\r\n const source = createInMemoryDataSource(seed, customersSchema)\r\n\r\n let view = $state<ServerState<Customer>>({\r\n rows: [], total: 0, loading: false, saving: false, error: null,\r\n pageIndex: 0, pageSize: 10, pageCount: 1, sortModel: [], filterModel: {},\r\n })\r\n let editing = $state<Customer | null | undefined>(undefined)\r\n let genId = 4\r\n\r\n const controller = createServerDataSource(source, {\r\n pageSize: 10, optimistic: true, getRowId: (r) => r.id,\r\n onChange: (s) => (view = s),\r\n })\r\n controller.refresh()\r\n\r\n async function save({ mode, id, values }) {\r\n if (mode === 'create') { await controller.createRow({ id: `c${genId++}`, ...values }); controller.setPage(view.pageCount - 1) }\r\n else if (id) { await controller.updateRow(id, values) }\r\n editing = undefined\r\n }\r\n</script>\r\n\r\n<style>\r\n :global(body) {\r\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\r\n }\r\n</style>\r\n\r\n<button onclick={() => (editing = null)}>+ New customer</button>\r\n\r\n<SvGrid\r\n data={view.rows} {columns} loading={view.loading}\r\n fitColumns enableRowSummaries={false}\r\n sortable externalSort onSortingChange={(s) => controller.setSort(s)}\r\n filterable filterMode=\"row\" externalFilter\r\n onFiltersChange={(f) => controller.setFilter({\r\n global: f.global || undefined,\r\n columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])),\r\n })}\r\n onRowClick={(e) => (editing = e.row)}\r\n showPagination externalPagination\r\n rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\r\n onPaginationChange={({ pageIndex, pageSize }) => pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex)}\r\n/>\r\n\r\n{#if editing !== undefined}\r\n <SvGridEditPanel schema={customersSchema} row={editing} presentation=\"modal\"\r\n onSubmit={save} onCancel={() => (editing = undefined)} />\r\n{/if}\r\n```\r\n\r\nThe `<style>` block is just a plain font reset - a fresh `npx sv create` app ships no CSS\r\nat all, so without it the page falls back to the browser's default serif font. `<SvGrid>`\r\nand `<SvGridEditPanel>` already theme their own borders, backgrounds, and hover states out\r\nof the box (via [`--sg-*` tokens](../../help/tokens.md) with built-in fallbacks) - font is\r\nthe one thing they intentionally inherit from the page rather than force, so it fits\r\nwhatever type your app already uses. If your app already sets a body font (or a\r\n[`--sg-font`](./theming.md) token), skip this block.\r\n\r\n---\r\n\r\n## See also\r\n\r\n- [SvGrid Studio overview](../studio.md)\r\n- [Concepts](./concepts.md) - the mental model + glossary\r\n- [Data binding](./data-binding.md) - the `ServerDataSource` contract in depth\r\n- [Edit forms & validation](./edit-forms.md)\r\n- [Troubleshooting & FAQ](./troubleshooting.md)\r\n"
2813
+ "markdown": "# Getting started\r\n\r\nThis is the gentlest path into SvGrid Studio. By the end you will have a real,\r\nworking **Customers** screen - a grid you can sort, filter, and page, with a\r\ncreate / edit form and delete - running on your machine. No prior experience\r\nwith SvGrid is assumed.\r\n\r\nIf you would rather just click around first, open a live demo - no install\r\nneeded:\r\n\r\n- **[Studio live SQL](https://svgrid.com/demos/193-studio-live-sql/)** - the whole stack in the browser, backed by real Postgres via PGlite\r\n- **[Live SQL](https://svgrid.com/demos/193-studio-live-sql/)** - a real Postgres in the browser (PGlite)\r\n- **[Supabase](https://svgrid.com/demos/194-studio-supabase/)** - connect your own hosted Postgres\r\n\r\n**Choose your tutorial.** This page needs no database and teaches the whole\r\nshape; the one-page tutorials build the same screen against a real backend.\r\nThey all end in the same place, so pick by what you have:\r\n\r\n| You have | Follow | What it adds |\r\n| --- | --- | --- |\r\n| Nothing yet | this page | the full path: install, schema, generate, run, change |\r\n| A Postgres connection string | [Postgres CRUD grid](./postgres-grid.md) | a server route querying your database |\r\n| A Supabase project | [Supabase CRUD grid](./supabase-grid.md) | browser client, keys, Row-Level Security |\r\n| An HTTP / JSON API | [REST CRUD grid](./rest-grid.md) | the REST adapter, no server route at all |\r\n| An afternoon | [Build a CRM](./tutorial-crm.md) | multi-entity: relations, master-detail, a real DB |\r\n\r\n---\r\n\r\n## Guided path - answer a few questions, get the app\r\n\r\nIf you already know where your data lives, let Studio ask:\r\n\r\n```bash\r\nnpx @svgrid/studio init\r\n```\r\n\r\nIt asks four things - where the data comes from (sample data, your database,\r\nin-browser Postgres, or a REST API), which tables you want, which pages each\r\ntable gets, and what it should look like - then writes a runnable SvelteKit app\r\nwith a list, an edit form and a record page per table, plus an overview\r\ndashboard.\r\n\r\nPointing it at a real database is one line, and Studio installs the driver for\r\nyou:\r\n\r\n```bash\r\nnpx @svgrid/studio init --db postgres --url $DATABASE_URL --out my-app\r\n```\r\n\r\nPrefer clicking? The visual designer has the same wizard behind its **New app**\r\nbutton - or open [svgrid.com/studio/new](https://svgrid.com/studio/new) to start\r\none in the browser. Both paths run the same generator, so they produce the same\r\napp. See [The Studio CLI](./cli.md#init) for every flag.\r\n\r\n---\r\n\r\n## Fastest path - a downloadable, ready-to-run example\r\n\r\nRather have a working project on your machine than type code into a blank\r\nfile? One command scaffolds a complete SvelteKit app with everything already\r\nwired up:\r\n\r\n```bash\r\nnpm create @svgrid/studio@latest my-app\r\n```\r\n\r\nYou'll be asked to pick a **theme** - one of `@svgrid/grid`'s 19 built-in\r\npresets (shadcn, Tailwind, Material, Excel, Fluent, and more) - and whether to\r\nstart in **light or dark** mode. Scripting this instead? Both are flags:\r\n\r\n```bash\r\nnpm create @svgrid/studio@latest my-app -- --theme material --dark\r\n```\r\n\r\nThen:\r\n\r\n```bash\r\ncd my-app\r\nnpm install\r\nnpm run dev\r\n```\r\n\r\nOpen `http://localhost:5173`. You get:\r\n\r\n- A **nav shell** and a home page (`src/routes/+layout.svelte`).\r\n- Two linked entities - **Customers** and **Orders** - each a full grid +\r\n modal create/edit/delete screen. Orders has a searchable lookup back to\r\n Customers, so you can see how relations work.\r\n- **Seeded in-memory data** - nothing to install or configure, no database.\r\n- The **theme and mode you picked**, applied to the whole app - not just the grid.\r\n\r\nIt is a real project, not a read-only demo - edit it, add fields, connect a\r\ndatabase, deploy it. A few places to start:\r\n\r\n| Want to... | Edit |\r\n| --- | --- |\r\n| Add or change a field | `src/lib/schemas.ts` - the grid and the form update together |\r\n| See how a screen is built | `src/lib/EntityScreen.svelte` - the reusable grid + modal CRUD screen every route uses |\r\n| Connect a real database | `src/lib/data.ts` - swap `createInMemoryDataSource` for `createSqlDataSource` / `createSupabaseDataSource` (see [Databases](./databases.md)) |\r\n| Add another screen from a live table | `npx @svgrid/studio add invoices --db postgres --url \"$DATABASE_URL\"` |\r\n\r\nThe template's own `README.md` covers the same ground once you're in the\r\nproject. Prefer to see each piece built up by hand instead, or add a screen to\r\nan *existing* app rather than a fresh one? Continue below.\r\n\r\n---\r\n\r\n## Three ways to build - pick yours\r\n\r\n![Build a data app in four no-code steps: open the designer, start from a sample or your database, arrange it visually, then generate the app.](/docs-media/studio-nocode-steps.svg)\r\n\r\nThis is the visual designer - a screens list on the left, your data previewed\r\nlive in the middle, and simple property panels on the right. You point, click, and\r\npress **Generate**:\r\n\r\n![The visual app designer with a customer grid previewed live and a properties panel for the screen and its fields.](/docs-media/studio-app-designer.png)\r\n\r\n- **Visual designer (no code).** You never write code - you point, click, and\r\n preview, then press one button to generate the finished app. Try it\r\n immediately, no install, at **[svgrid.com/studio](https://svgrid.com/studio)** -\r\n or run it locally with **[Launch the designer](./launch.md)**, which\r\n auto-saves to disk and generates straight into a folder. The\r\n [sample apps](./samples.md) let you open a complete, realistic app in one\r\n click and point it at your own data.\r\n- **CLI.** One deterministic command per screen: `npx @svgrid/studio add ...`\r\n introspects your table or schema and writes the files. No AI involved. This\r\n page uses the CLI from Step 3 on - continue below.\r\n- **AI via MCP.** With [`@svgrid/mcp`](./ai-generation.md) configured, ask your\r\n coding agent to build the screen; it introspects, scaffolds, and\r\n compile-verifies through the same core the CLI uses.\r\n\r\nAll three produce the **same generated code** - pick whichever fits how you\r\nwork, and switch freely later.\r\n\r\n---\r\n\r\n## What you need\r\n\r\n- **[Node.js](https://nodejs.org) 18 or newer.** Check with `node -v` in a\r\n terminal. If that errors, install Node first.\r\n- **A terminal** and a code editor (VS Code is fine).\r\n- **A SvelteKit app.** Don't have one? Create one in 30 seconds:\r\n\r\n ```bash\r\n npx sv create my-app # pick \"SvelteKit minimal\", TypeScript: yes\r\n cd my-app\r\n npm install\r\n ```\r\n\r\nYou do **not** need a database to start - the first screen below runs on\r\nin-memory data. You can point it at PostgreSQL, Supabase, MySQL, and others\r\nlater without changing the UI.\r\n\r\n---\r\n\r\n## Step 1 - Install\r\n\r\nInside your app folder:\r\n\r\n```bash\r\nnpm i @svgrid/grid @svgrid/enterprise\r\n```\r\n\r\n- `@svgrid/grid` is the grid itself.\r\n- `@svgrid/enterprise` adds Studio: the schema, the edit form, and the data-source\r\n helpers. It is **soft-gate only** - everything runs unlicensed, it just nudges.\r\n See [licensing](../licensing.md).\r\n\r\n---\r\n\r\n## Step 2 - Describe your data once\r\n\r\nThe generator needs one description of your table. If you have a live database\r\nit can introspect it directly (Step 3 shows that variant). Here we stay\r\ndatabase-free: describe the table in a small Drizzle schema file, which the\r\ngenerator **reads as text** - it never connects to anything.\r\n\r\n```bash\r\nnpm i -D drizzle-orm\r\n```\r\n\r\n(`drizzle-orm` is only there so the schema file type-checks; nothing runs\r\nagainst a database. It is also the natural next step when you do add one.)\r\n\r\nCreate `src/lib/db/schema.ts`:\r\n\r\n```ts\r\nimport { pgTable, text, integer, boolean } from 'drizzle-orm/pg-core'\r\n\r\nexport const customers = pgTable('customers', {\r\n id: text('id').primaryKey(),\r\n name: text('name').notNull(),\r\n email: text('email').notNull(),\r\n tier: text('tier').notNull().default('free'),\r\n mrr: integer('mrr'),\r\n active: boolean('active'),\r\n})\r\n```\r\n\r\nA Prisma `schema.prisma` works the same way. Prefer to write Studio's own\r\nmodel - the `EntitySchema` - by hand instead? That is the\r\n[appendix](#appendix-wire-it-by-hand-no-generator) at the bottom of this page.\r\n\r\n---\r\n\r\n## Step 3 - Generate the screen\r\n\r\nOne command:\r\n\r\n```bash\r\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\r\n```\r\n\r\nHave a live database instead? Same command, different source - no schema file\r\nneeded:\r\n\r\n```bash\r\nnpx @svgrid/studio add customers --db postgres --url \"$DATABASE_URL\"\r\n```\r\n\r\nEither way it writes **three files**, and the screen is done. A quick tour of\r\nwhat you now own:\r\n\r\n**1. `src/lib/customers.schema.ts` - the model.** The generator turned your\r\ntable into an `EntitySchema` - the single object that drives the grid columns,\r\nthe form fields, and validation:\r\n\r\n```ts\r\nexport type CustomersRow = {\r\n id: string\r\n name: string\r\n email: string\r\n tier: string\r\n mrr: number | null\r\n active: boolean | null\r\n}\r\n\r\nexport const customersSchema: EntitySchema<CustomersRow> = {\r\n name: 'customers',\r\n idField: 'id',\r\n fields: [\r\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\r\n { field: 'name', type: 'text', required: true },\r\n { field: 'email', type: 'text', required: true },\r\n // ...\r\n ],\r\n}\r\n```\r\n\r\nEvery field option (labels, enum choices, min/max, regex, custom validators) is\r\nexplained in [The EntitySchema](./schema.md).\r\n\r\n**2. `src/routes/api/customers/+server.ts` - the API route.** A\r\n`ServerDataSource` (read + create + update + delete) exposed over one SvelteKit\r\nendpoint. With `--from` it starts in-memory so it runs immediately; with `--db`\r\nit is already wired to your database:\r\n\r\n```ts\r\nconst source = createInMemoryDataSource<CustomersRow>([], customersSchema)\r\n\r\nexport const { POST } = createKitHandlers({ schema: customersSchema, source })\r\n```\r\n\r\nSwapping in a real database later means replacing that one `source` line - the\r\npage never changes. See [Databases](./databases.md).\r\n\r\n**3. `src/routes/customers/+page.svelte` - the screen.** The grid with\r\nserver-side sort, filter, global search, a native pagination footer,\r\nmulti-select delete with optimistic updates, and a modal create / edit form -\r\nall reading through the API route:\r\n\r\n```ts\r\nconst source = createKitDataSource<CustomersRow>({ endpoint: '/api/customers' })\r\nconst columns = schemaToColumns(customersSchema)\r\nconst controller = createServerDataSource<CustomersRow>(source, {\r\n pageSize: 25, optimistic: true,\r\n getRowId: (r) => String(r.id),\r\n onChange: (s) => (state = s),\r\n})\r\n```\r\n\r\nEach file wraps its generated body in `// svgrid:managed:start` /\r\n`// svgrid:managed:end` markers. Everything you write **outside** the markers\r\nis yours; re-running `add` only rewrites what is inside. That is what makes\r\nStep 5 safe.\r\n\r\n> One thing the generated screen inherits from your page: the **font**. A bare\r\n> `npx sv create` app sets no CSS at all, so add a\r\n> `body { font-family: system-ui, sans-serif }` rule (or a\r\n> [`--sg-font`](./theming.md) token) once, or the page renders in the\r\n> browser's default serif. Borders, backgrounds, and hover states the grid\r\n> themes itself.\r\n\r\n---\r\n\r\n## Step 4 - Run it\r\n\r\n```bash\r\nnpm run dev\r\n```\r\n\r\nOpen the URL it prints (usually `http://localhost:5173`) and go to\r\n**`/customers`**. With `--from` the grid starts empty (in-memory source, no\r\nseed) - click **New** and add two or three customers, then try the screen:\r\n\r\n- Click a **column header** to sort.\r\n- Type in the **filter row** under a header to filter (it stays focused as you type).\r\n- Click a **row** to edit. The form validates as you type - clear a required\r\n field and watch it complain.\r\n- Select rows with the checkboxes and **Delete** them - the grid updates\r\n instantly and rolls back if the server says no.\r\n- Page through with the **native pager** at the bottom.\r\n\r\n![A CRUD screen generated by SvGrid Studio: sortable, filterable grid with a native pager, over a live data source.](/docs-media/studio-crud.png)\r\n\r\n---\r\n\r\n## Step 5 - Change something\r\n\r\nBecause the schema drives everything, changes are one edit. Add a column to\r\n`src/lib/db/schema.ts`:\r\n\r\n```ts\r\ncountry: text('country'),\r\n```\r\n\r\nThen re-run the exact same command:\r\n\r\n```bash\r\nnpx @svgrid/studio add customers --from src/lib/db/schema.ts\r\n```\r\n\r\nThe managed regions are regenerated: the grid gets a **Country** column and the\r\nedit form gets a **Country** input. Anything you wrote outside the\r\n`svgrid:managed` markers - extra buttons, styles, handlers - is untouched.\r\nThat round trip (change schema, re-run, keep your code) is the everyday\r\nworkflow; [Code generation](./code-generation.md) explains the rules.\r\n\r\nPrefer not to re-run the generator? Editing the generated\r\n`customers.schema.ts` directly works too - grid and form update together from\r\nthe one schema object.\r\n\r\n---\r\n\r\n## Where to go next\r\n\r\nYou have the whole shape now. The usual next steps:\r\n\r\n- **Understand the model** - [Concepts](./concepts.md) walks the pipeline\r\n (schema, screens, data source, codegen) once and defines every Studio term.\r\n- **Connect a real database** - re-run `add` with `--db` and a connection\r\n string, or swap the one `source` line in the API route; the page does not\r\n change. For Supabase, follow the one-page\r\n **[Supabase CRUD grid tutorial](./supabase-grid.md)**; for SQL, see\r\n [Databases](./databases.md).\r\n- **Design visually** - `npx @svgrid/studio designer` opens the full app builder:\r\n compose screens across entities, bind data, and click *Generate app*. See the\r\n [Visual app designer](./app-designer.md). (To embed a single-entity schema editor\r\n in your own app, see the [Schema designer](./designer.md).)\r\n- **Build a full app** - the [Build a CRM tutorial](./tutorial-crm.md) wires up\r\n companies, contacts, and deals with relations and master-detail.\r\n\r\n---\r\n\r\n## Appendix: wire it by hand (no generator)\r\n\r\nEverything the generator wrote in Step 3 can be built up by hand - useful when\r\nyou want to see exactly how the pieces fit, or to embed a Studio screen in an\r\nunusual spot. Two files replace the three generated ones (no API route: here\r\nthe data source lives in the page itself).\r\n\r\nFirst, the `EntitySchema` - Studio's own model, the object the generator\r\nderived from your Drizzle file. Create `src/lib/customers.ts`:\r\n\r\n```ts\r\nimport type { EntitySchema } from '@svgrid/enterprise'\r\n\r\nexport type Customer = {\r\n id: string\r\n name: string\r\n email: string\r\n tier: 'free' | 'pro' | 'enterprise'\r\n mrr: number\r\n active: boolean\r\n}\r\n\r\nexport const customersSchema: EntitySchema<Customer> = {\r\n name: 'customers',\r\n label: 'Customer',\r\n idField: 'id',\r\n fields: [\r\n { field: 'id', type: 'text', primaryKey: true, readonly: true },\r\n { field: 'name', type: 'text', required: true, minLength: 2 },\r\n { field: 'email', type: 'text', label: 'Email', required: true, format: 'email' },\r\n { field: 'tier', type: 'enum', options: [\r\n { value: 'free', label: 'Free' },\r\n { value: 'pro', label: 'Pro' },\r\n { value: 'enterprise', label: 'Enterprise' },\r\n ] },\r\n { field: 'mrr', type: 'number', label: 'MRR ($)', min: 0 },\r\n { field: 'active', type: 'boolean' },\r\n ],\r\n}\r\n```\r\n\r\nThen the page. `createInMemoryDataSource` provides the `ServerDataSource`\r\ncontract over a plain array, `createServerDataSource` runs sort / filter /\r\npage / CRUD against it, and the grid + edit panel render it. Create\r\n`src/routes/customers/+page.svelte`:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, createServerDataSource, type ServerState } from '@svgrid/grid'\r\n import { SvGridEditPanel, createInMemoryDataSource, schemaToColumns } from '@svgrid/enterprise'\r\n import { customersSchema, type Customer } from '$lib/customers'\r\n\r\n const seed: Customer[] = [\r\n { id: 'c1', name: 'Ada Lovelace', email: 'ada@analytic.io', tier: 'enterprise', mrr: 1200, active: true },\r\n { id: 'c2', name: 'Alan Turing', email: 'alan@bletchley.uk', tier: 'pro', mrr: 240, active: true },\r\n { id: 'c3', name: 'Grace Hopper', email: 'grace@navy.mil', tier: 'enterprise', mrr: 980, active: true },\r\n ]\r\n\r\n const columns = schemaToColumns(customersSchema)\r\n const source = createInMemoryDataSource(seed, customersSchema)\r\n\r\n let view = $state<ServerState<Customer>>({\r\n rows: [], total: 0, loading: false, saving: false, error: null,\r\n pageIndex: 0, pageSize: 10, pageCount: 1, sortModel: [], filterModel: {},\r\n })\r\n let editing = $state<Customer | null | undefined>(undefined)\r\n let genId = 4\r\n\r\n const controller = createServerDataSource(source, {\r\n pageSize: 10, optimistic: true, getRowId: (r) => r.id,\r\n onChange: (s) => (view = s),\r\n })\r\n controller.refresh()\r\n\r\n async function save({ mode, id, values }) {\r\n if (mode === 'create') { await controller.createRow({ id: `c${genId++}`, ...values }); controller.setPage(view.pageCount - 1) }\r\n else if (id) { await controller.updateRow(id, values) }\r\n editing = undefined\r\n }\r\n</script>\r\n\r\n<style>\r\n :global(body) {\r\n font-family: ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\r\n }\r\n</style>\r\n\r\n<button onclick={() => (editing = null)}>+ New customer</button>\r\n\r\n<SvGrid\r\n data={view.rows} {columns} loading={view.loading}\r\n fitColumns enableRowSummaries={false}\r\n sortable externalSort onSortingChange={(s) => controller.setSort(s)}\r\n filterable filterMode=\"row\" externalFilter\r\n onFiltersChange={(f) => controller.setFilter({\r\n global: f.global || undefined,\r\n columns: Object.fromEntries(f.columns.map((c) => [c.id, { operator: c.operator, value: c.value, valueTo: c.valueTo, selectedValues: c.selectedValues }])),\r\n })}\r\n onRowClick={(e) => (editing = e.row)}\r\n showPagination externalPagination\r\n rowCount={view.total} pageIndex={view.pageIndex} pageSize={view.pageSize}\r\n onPaginationChange={({ pageIndex, pageSize }) => pageSize !== view.pageSize ? controller.setPageSize(pageSize) : controller.setPage(pageIndex)}\r\n/>\r\n\r\n{#if editing !== undefined}\r\n <SvGridEditPanel schema={customersSchema} row={editing} presentation=\"modal\"\r\n onSubmit={save} onCancel={() => (editing = undefined)} />\r\n{/if}\r\n```\r\n\r\nThe `<style>` block is just a plain font reset - a fresh `npx sv create` app ships no CSS\r\nat all, so without it the page falls back to the browser's default serif font. `<SvGrid>`\r\nand `<SvGridEditPanel>` already theme their own borders, backgrounds, and hover states out\r\nof the box (via [`--sg-*` tokens](../../help/tokens.md) with built-in fallbacks) - font is\r\nthe one thing they intentionally inherit from the page rather than force, so it fits\r\nwhatever type your app already uses. If your app already sets a body font (or a\r\n[`--sg-font`](./theming.md) token), skip this block.\r\n\r\n---\r\n\r\n## See also\r\n\r\n- [SvGrid Studio overview](../studio.md)\r\n- [Concepts](./concepts.md) - the mental model + glossary\r\n- [Data binding](./data-binding.md) - the `ServerDataSource` contract in depth\r\n- [Edit forms & validation](./edit-forms.md)\r\n- [Troubleshooting & FAQ](./troubleshooting.md)\r\n"
2793
2814
  },
2794
2815
  {
2795
2816
  "slug": "enterprise/studio/i18n",
@@ -2945,7 +2966,7 @@ export const docs = [
2945
2966
  "slug": "getting-started-full",
2946
2967
  "path": "docs/getting-started-full.md",
2947
2968
  "title": "Getting Started with SvGrid",
2948
- "markdown": "# Getting Started with SvGrid\r\n\r\nSvGrid is a modern, production-ready data grid for Svelte 5 - a headless\r\ncore engine paired with a Svelte render component\r\n(`<SvGrid>`). It scales from a 10-row read-only table to a virtualized\r\n100,000-row, 100-column editing surface with grouping, multi-column\r\nfiltering, server-side data, and full keyboard and screen-reader\r\nsupport.\r\n\r\nThis page walks you from `pnpm add` to a feature-complete grid. It is\r\nthe canonical entry point - every other page in the documentation\r\nassumes you've finished this one. Estimated reading time: 15 minutes.\r\n\r\n> **New here?** Two short companion reads:\r\n>\r\n> - [Why headless?](./why-headless.md) - the architecture decision\r\n> behind the `createSvGrid` core vs. the `<SvGrid>` renderer.\r\n> - [Tailwind integration](./help/tailwind.md) - how `--sg-*` custom\r\n> properties + Tailwind v4 + dark mode fit together.\r\n\r\n> `@svgrid/grid` is published under the **MIT License** - permissive\r\n> for commercial use, redistribution, and modification. The paid companion\r\n> `@svgrid/enterprise` (data export + print) ships under a separate commercial\r\n> license. See [LICENSE](../LICENSE) and\r\n> [packages/enterprise/LICENSE](../packages/enterprise/LICENSE).\r\n\r\n---\r\n\r\n## Contents\r\n\r\n1. [Your first grid in 60 seconds](#1-your-first-grid-in-60-seconds)\r\n2. [Install the package](#2-install-the-package)\r\n3. [Provide row data](#3-provide-row-data)\r\n4. [Define column definitions](#4-define-column-definitions)\r\n5. [Register features (row models)](#5-register-features-row-models)\r\n6. [Styling: theme, density, dark mode](#6-styling-theme-density-dark-mode)\r\n7. [Sizing the grid](#7-sizing-the-grid)\r\n8. [Custom cells with FlexRender](#8-custom-cells-with-flexrender)\r\n9. [Sorting, filtering, pagination](#9-sorting-filtering-pagination)\r\n10. [Selection, editing, keyboard](#10-selection-editing-keyboard)\r\n11. [Server-side data](#11-server-side-data)\r\n12. [Virtualization for large datasets](#12-virtualization-for-large-datasets)\r\n13. [Accessibility](#13-accessibility)\r\n14. [TypeScript notes](#14-typescript-notes)\r\n15. [What's next](#15-whats-next)\r\n\r\n---\r\n\r\n## 1. Your first grid in 60 seconds\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, type ColumnDef } from '@svgrid/grid'\r\n\r\n type Person = { firstName: string; age: number; status: string }\r\n\r\n const rows: Person[] = [\r\n { firstName: 'Ada', age: 36, status: 'active' },\r\n { firstName: 'Linus', age: 54, status: 'active' },\r\n { firstName: 'Grace', age: 85, status: 'inactive' },\r\n ]\r\n\r\n const columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName', header: 'First name' },\r\n { field: 'age', header: 'Age' },\r\n { field: 'status', header: 'Status' },\r\n ]\r\n</script>\r\n\r\n<SvGrid data={rows} columns={columns} />\r\n```\r\n\r\nThat's a complete, working grid. The rest of this page is about turning\r\nit into something you'd ship.\r\n\r\n---\r\n\r\n## 2. Install the package\r\n\r\nSvGrid is a single npm package. There is no peer dependency on a CSS\r\nframework - bring your own, or use the bundled stylesheet.\r\n\r\n```bash\r\n# pnpm (recommended)\r\npnpm add @svgrid/grid\r\n\r\n# npm\r\nnpm install @svgrid/grid\r\n\r\n# yarn\r\nyarn add @svgrid/grid\r\n```\r\n\r\n**Requirements.**\r\n\r\n- Svelte **5.x** (uses runes - `$state`, `$derived`, `$effect`).\r\n- TypeScript **5.4+** (optional but recommended).\r\n- Node **18+** for tooling.\r\n\r\nOnce installed, import the component, the features you want, and the\r\nmatching `ColumnDef` type:\r\n\r\n```ts\r\nimport {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n type ColumnDef,\r\n} from '@svgrid/grid'\r\n```\r\n\r\nThe bundle is tree-shakeable - features you don't import don't ship. The\r\ndefault render component (`<SvGrid>`) brings its own scoped CSS, so\r\nthere's no separate stylesheet to import. Re-theming happens via the\r\n`--sg-*` custom-property surface; see\r\n[Tailwind integration](./help/tailwind.md) for the full list.\r\n\r\n---\r\n\r\n## 3. Provide row data\r\n\r\nSvGrid is data-agnostic. The `data` prop is any\r\n`ReadonlyArray<TRow>` - a Svelte 5 `$state` array, a derived store, an\r\nSWR/React-query-style cache, the result of a `+page.ts` load function,\r\nor a plain literal.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid } from '@svgrid/grid'\r\n\r\n type Person = { id: string; firstName: string; age: number }\r\n\r\n // Reactive: pushing into `rows` updates the grid automatically.\r\n let rows = $state<Person[]>([\r\n { id: '1', firstName: 'Ada', age: 36 },\r\n { id: '2', firstName: 'Linus', age: 54 },\r\n ])\r\n\r\n function addRow() {\r\n rows.push({ id: crypto.randomUUID(), firstName: 'New', age: 0 })\r\n }\r\n</script>\r\n\r\n<button onclick={addRow}>Add row</button>\r\n<SvGrid data={rows} columns={columns} />\r\n```\r\n\r\n**Identity.** Today the wrapper uses the row's array index as its id.\r\nThat is fine for read-only data; if you mutate `rows`, prefer keeping\r\nthe same object references for rows that didn't change so selection\r\nand edit state line up. A `getRowId` prop on the wrapper is tracked in\r\n[Missing features](./help/missing-features.md) and supported by the\r\nheadless `createSvGrid` core today.\r\n\r\n**Immutability.** SvGrid never mutates your data. When you edit a cell\r\nthe grid emits an event; you decide whether to mutate in place or copy.\r\nSee [§10 - Editing](#10-selection-editing-keyboard).\r\n\r\n---\r\n\r\n## 4. Define column definitions\r\n\r\nA column definition tells SvGrid how to read a value out of a row, how\r\nto render it, and which features apply to it.\r\n\r\n```ts\r\nimport type { ColumnDef } from '@svgrid/grid'\r\n\r\ntype Person = {\r\n id: string\r\n firstName: string\r\n lastName: string\r\n age: number\r\n joinedAt: string // ISO date\r\n salary: number\r\n active: boolean\r\n}\r\n\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n // Simple accessor by key\r\n { field: 'firstName', header: 'First name' },\r\n\r\n // Computed accessor\r\n {\r\n id: 'fullName',\r\n header: 'Full name',\r\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\r\n },\r\n\r\n // Numeric with locale-aware formatting\r\n {\r\n field: 'age',\r\n header: 'Age',\r\n format: { type: 'number', options: { maximumFractionDigits: 0 } },\r\n },\r\n\r\n // Date with explicit pattern\r\n {\r\n field: 'joinedAt',\r\n header: 'Joined',\r\n format: { type: 'date', pattern: 'y-m-d' },\r\n },\r\n\r\n // Currency\r\n {\r\n field: 'salary',\r\n header: 'Salary',\r\n format: { type: 'currency', currency: 'USD' },\r\n },\r\n\r\n // Boolean rendered as a checkbox\r\n {\r\n field: 'active',\r\n header: 'Active',\r\n editorType: 'checkbox',\r\n },\r\n]\r\n```\r\n\r\n**Common properties.**\r\n\r\n| Property | Purpose |\r\n| --- | --- |\r\n| `field` | Reads `row[key]`. |\r\n| `fieldFn` | Computes the value from the row. |\r\n| `id` | Stable column id (required if you use `fieldFn`). |\r\n| `header` | String or render snippet for the header. |\r\n| `footer` | String or render snippet for the footer row. |\r\n| `cell` | Render snippet/component for the body cell. |\r\n| `format` | Locale-aware formatter (`number`, `currency`, `percent`, `date`). |\r\n| `formatter` | Function for one-off custom value formatting. |\r\n| `editorType` | Inline editor: `text` \\| `number` \\| `checkbox` \\| `date` \\| `datetime`. |\r\n| `width` | Initial column width in pixels (default `columnWidth` prop). |\r\n| `align` | Header + body alignment: `'left'` \\| `'right'` \\| `'center'`. Inferred from `editorType` when omitted. |\r\n| `columns` | Child column defs (for column groups). |\r\n\r\nSorting / filtering / grouping are toggled per-grid via the registered\r\nfeatures - there is no per-column `enableSorting` / `enableColumnFilter`\r\nflag yet; those entries are in [Missing features](./help/missing-features.md).\r\n\r\nSee [`packages/grid/src/core.ts`](../packages/grid/src/core.ts)\r\nfor the full type.\r\n\r\n---\r\n\r\n## 5. Register features (row models)\r\n\r\nThe grid engine is feature-gated. Out of the box you get the **core row\r\nmodel** (the rows in their original order). To enable sorting,\r\nfiltering, grouping, expansion, pagination, or selection you opt in\r\nwith `tableFeatures(...)` and the matching `create*RowModel` factory.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n type ColumnDef,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination={true}\r\n pageSize={25}\r\n/>\r\n```\r\n\r\n**Rule of thumb.** Only register the features you use. The wrapper wires\r\nthe matching row-model pipeline (core → filtered → sorted → grouped →\r\nexpanded) for you and exposes the user-facing toggles via props\r\n(`showPagination`, `filterMode`, `showRowSelection`, …). If you need the\r\nheadless pipeline directly - e.g. a custom renderer - drop down to\r\n`createSvGrid` from the same package; see [Why headless?](./why-headless.md).\r\n\r\n| Feature | Factory | What it does |\r\n| --- | --- | --- |\r\n| `rowSortingFeature` | `createSortedRowModel` | Click headers to sort; shift-click for multi-sort. |\r\n| `columnFilteringFeature` | `createFilteredRowModel` | Per-column filters with built-in `filterFns`. |\r\n| `rowPaginationFeature` | `createPaginatedRowModel` | Page slicing + footer state. |\r\n| `rowSelectionFeature` | - | Row checkboxes, range selection, headless API. |\r\n| `columnGroupingFeature` | `createGroupedRowModel` | Group-by-column + aggregators. |\r\n| `rowExpandingFeature` | `createExpandedRowModel` | Tree / master-detail expansion. |\r\n\r\n---\r\n\r\n## 6. Styling: theme, density, dark mode\r\n\r\nSvGrid's render component (`<SvGrid>`) ships its own scoped styles. You\r\nre-theme it by declaring `--sg-*` custom properties at any level above\r\nthe grid - the included light, dark, and high-contrast palettes in the\r\n[`10-custom-cells-and-themes`](../examples/src/demos/10-custom-cells-and-themes.svelte)\r\ndemo are themselves just three sets of these tokens applied via\r\n`style=\"--sg-bg: …; --sg-fg: …; …\"`.\r\n\r\n**Customising tokens.** Override at any level - `:root`, a wrapper, or\r\ndirectly on `<SvGrid>`.\r\n\r\n```css\r\n:root {\r\n --sg-row-height: 36px;\r\n --sg-header-bg: #f6f7f9;\r\n --sg-header-fg: #1f2933;\r\n --sg-row-hover-bg: #eef2ff;\r\n --sg-selection-bg: #dbeafe;\r\n --sg-border: #e5e7eb;\r\n --sg-focus-ring: 0 0 0 2px #2563eb;\r\n --sg-font: 'Inter', system-ui, sans-serif;\r\n}\r\n\r\n@media (prefers-color-scheme: dark) {\r\n :root {\r\n --sg-header-bg: #0f172a;\r\n --sg-header-fg: #f1f5f9;\r\n --sg-row-hover-bg: #1e293b;\r\n --sg-border: #334155;\r\n }\r\n}\r\n```\r\n\r\n**Density.** The default theme reads `--sg-row-height`; flip it to\r\n`28px` for compact mode and `48px` for comfortable. Density changes are\r\napplied without remounting the virtualizer.\r\n\r\n**Reduced motion.** Sort animations and expand transitions respect\r\n`prefers-reduced-motion: reduce` automatically.\r\n\r\n---\r\n\r\n## 7. Sizing the grid\r\n\r\n`<SvGrid>` fills its parent. Give it a height and it scrolls - without\r\none, it expands to its content and never virtualises.\r\n\r\n```svelte\r\n<!-- Fixed: 600px tall, full width. The typical choice. -->\r\n<div style=\"height: 600px;\">\r\n <SvGrid data={rows} columns={columns} />\r\n</div>\r\n\r\n<!-- Flexible: fills the viewport minus header/footer. -->\r\n<div class=\"grid-shell\">\r\n <SvGrid data={rows} columns={columns} />\r\n</div>\r\n\r\n<style>\r\n .grid-shell {\r\n height: calc(100dvh - 4rem);\r\n }\r\n</style>\r\n```\r\n\r\n**Auto-height (small datasets only).** For grids with fewer than ~200\r\nrows you can let the grid grow to its content:\r\n\r\n```svelte\r\n<SvGrid data={rows} columns={columns} domLayout=\"autoHeight\" />\r\n```\r\n\r\nAuto-height disables row virtualization. Don't use it for large data.\r\n\r\n---\r\n\r\n## 8. Custom cells with FlexRender\r\n\r\nFor anything beyond a stringified value, render with `FlexRender`,\r\n`renderComponent`, or `renderSnippet`.\r\n\r\n### As a Svelte snippet\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { renderSnippet, type ColumnDef } from '@svgrid/grid'\r\n</script>\r\n\r\n{#snippet StatusCell({ value }: { value: string })}\r\n <span class=\"pill pill-{value}\">{value}</span>\r\n{/snippet}\r\n\r\n<script lang=\"ts\">\r\n const columns: ColumnDef<{}, Person>[] = [\r\n {\r\n field: 'status',\r\n header: 'Status',\r\n cell: renderSnippet(StatusCell, (ctx) => ({ value: ctx.getValue() as string })),\r\n },\r\n ]\r\n</script>\r\n```\r\n\r\n### As a Svelte component\r\n\r\n```ts\r\nimport StatusBadge from './StatusBadge.svelte'\r\nimport { renderComponent } from '@svgrid/grid'\r\n\r\nconst columns = [\r\n {\r\n field: 'status',\r\n header: 'Status',\r\n cell: renderComponent(StatusBadge, (ctx) => ({ status: ctx.getValue() })),\r\n },\r\n]\r\n```\r\n\r\n`renderComponent` and `renderSnippet` both receive a\r\n`CellContext` so you can read sibling values, mutate state, or call\r\nback into the grid via `ctx.table`.\r\n\r\n---\r\n\r\n## 9. Sorting, filtering, pagination\r\n\r\nOnce their features are registered (see §5) the UI affordances appear\r\nautomatically. The state is controllable.\r\n\r\n**Quick way - capability shortcuts.** Every capability is off by default;\r\nthe fastest way to opt in is a boolean shortcut prop, no feature constants\r\nrequired. `sortable` and `filterable` inject the matching feature for you;\r\n`editable`, `groupable`, and `pageable` alias `enableInlineEditing`,\r\n`showGroupingControls`, and `showPagination`:\r\n\r\n```svelte\r\n<SvGrid data={rows} columns={columns} sortable filterable editable groupable pageable />\r\n```\r\n\r\nReach for the explicit `features` set + fine-grained props below when you\r\nneed more control (filter mode, page size, per-column opt-outs).\r\n\r\n### Uncontrolled (the default)\r\n\r\nThe wrapper owns sort, filter, pagination, selection, and expansion\r\nstate by default. Set the initial page size and which filter UI to\r\nshow via props:\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination={true}\r\n pageSize={50}\r\n filterMode=\"menu\"\r\n/>\r\n```\r\n\r\n### Observable (callbacks fire when state changes)\r\n\r\nThe wrapper still owns the state, but emits callbacks on every change.\r\nUse this when an outside piece of UI needs to react (a \"X rows\r\nselected\" pill, a router that syncs sort to the URL, a server fetch).\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\r\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n onSortingChange={(next) => (sorting = next)}\r\n onFiltersChange={(next) => (filters = next.columns)}\r\n/>\r\n```\r\n\r\n### External (you own row ordering / filtering)\r\n\r\nFor server-side data or tree-structured data the wrapper records the\r\nsort + filter UI state but does **not** re-order the rows - you do.\r\nPair `externalSort` / `externalFilter` with the callbacks above:\r\n\r\n```svelte\r\n<SvGrid\r\n data={preFilteredRows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n externalSort={true}\r\n externalFilter={true}\r\n onSortingChange={(next) => fetchPage({ sort: next, page: 0 })}\r\n onFiltersChange={(next) => fetchPage({ filters: next.columns, page: 0 })}\r\n/>\r\n```\r\n\r\nFor Excel-style filter operators and the active-filter chip UI, see\r\n[`applyExcelFilter`](../packages/grid/src/filtering/excel-filters.ts).\r\n\r\n---\r\n\r\n## 10. Selection, editing, keyboard\r\n\r\n### Row selection\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let selected = $state<Person[]>([])\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n selectionMode=\"row\"\r\n showRowSelection={true}\r\n onRowSelectionChange={(_state, rows) => (selected = rows)}\r\n/>\r\n\r\n{#if selected.length}\r\n <p>{selected.length} selected</p>\r\n{/if}\r\n```\r\n\r\n`selectionMode` is the umbrella prop: `'row'` shows the checkbox\r\ncolumn, `'cell'` enables click-and-drag range selection,\r\n`'both'` (default) enables both, `'none'` disables both. The\r\n`onRowSelectionChange` callback receives the selection record AND the\r\nmaterialised row array.\r\n\r\n### Cell editing\r\n\r\nSet `editorType` on each editable column. The grid handles entry,\r\ncommit, and cancel; you handle persistence.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n function handleCellEdit(event: {\r\n rowId: string\r\n columnId: string\r\n value: unknown\r\n }) {\r\n const row = rows.find((r) => r.id === event.rowId)\r\n if (!row) return\r\n ;(row as Record<string, unknown>)[event.columnId] = event.value\r\n }\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n getRowId={(row) => row.id}\r\n onCellValueChange={handleCellEdit}\r\n/>\r\n```\r\n\r\n### Keyboard\r\n\r\nThe grid follows the WAI-ARIA grid pattern:\r\n\r\n| Keys | Action |\r\n| --- | --- |\r\n| `←` `↑` `→` `↓` | Move active cell |\r\n| `Home` / `End` | First / last column of the row |\r\n| `Ctrl+Home` / `Ctrl+End` | First / last cell of the grid |\r\n| `PageUp` / `PageDown` | Move one viewport |\r\n| `Shift + <move>` | Extend cell-range selection |\r\n| `Space` | Toggle row selection (when selection enabled) |\r\n| `Enter` / `F2` | Begin editing the active cell |\r\n| `Esc` | Cancel edit / clear selection |\r\n| `Ctrl/Cmd + C` | Copy selection as TSV |\r\n| `Ctrl/Cmd + V` | Paste TSV into selection |\r\n\r\nIf you implement your own header or toolbar, route keys through\r\n`getKeyboardIntent` and `getNextActiveCell` so behaviour stays\r\nconsistent.\r\n\r\n---\r\n\r\n## 11. Server-side data\r\n\r\nFor datasets that don't fit in memory, drive the grid from the server.\r\nThe pattern is: turn the wrapper's `onSortingChange` /\r\n`onFiltersChange` callbacks into a query, fetch, hand the page back as\r\n`data`, and use the `externalSort` + `externalFilter` props so the grid\r\ndoesn't try to re-order rows it didn't fetch.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, tableFeatures, rowSortingFeature,\r\n columnFilteringFeature } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\r\n\r\n let sort = $state<Array<{ id: string; desc: boolean }>>([])\r\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\r\n let page = $state(0)\r\n const pageSize = 50\r\n\r\n let rows = $state<Person[]>([])\r\n let total = $state(0)\r\n let loading = $state(false)\r\n let controller: AbortController | null = null\r\n\r\n async function load() {\r\n controller?.abort()\r\n controller = new AbortController()\r\n loading = true\r\n try {\r\n const res = await fetch('/api/people?' + new URLSearchParams({\r\n sort: JSON.stringify(sort),\r\n filters: JSON.stringify(filters),\r\n page: String(page),\r\n size: String(pageSize),\r\n }), { signal: controller.signal })\r\n const body = await res.json()\r\n rows = body.rows\r\n total = body.total\r\n } catch (err) {\r\n if ((err as Error).name !== 'AbortError') throw err\r\n } finally {\r\n loading = false\r\n }\r\n }\r\n\r\n $effect(() => { sort; filters; page; load() })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n externalSort={true}\r\n externalFilter={true}\r\n showPagination={false}\r\n onSortingChange={(next) => { sort = next; page = 0 }}\r\n onFiltersChange={(next) => { filters = next.columns; page = 0 }}\r\n/>\r\n\r\n<nav>\r\n <button onclick={() => (page = Math.max(0, page - 1))}\r\n disabled={page === 0 || loading}>‹ Prev</button>\r\n <span>Page {page + 1} of {Math.ceil(total / pageSize)}</span>\r\n <button onclick={() => (page = page + 1)}\r\n disabled={(page + 1) * pageSize >= total || loading}>Next ›</button>\r\n</nav>\r\n\r\n{#if loading}<div class=\"overlay\">Loading…</div>{/if}\r\n```\r\n\r\nThe `external*` props tell the grid not to re-derive that dimension\r\nlocally - the data you pass in is already the answer. Pagination above\r\nis hand-rolled so total-row-count and \"show next page\" stay in your\r\ncontrol; if a built-in pager is enough, leave `showPagination={true}`\r\non and the wrapper will page the local `rows` array (which, in this\r\nserver-side pattern, only ever holds one page anyway).\r\n\r\nSee the [`09-server-side` demo](../examples/src/demos/09-server-side.svelte)\r\nfor a complete runnable version with debounce, abort wiring, and a\r\n60 ms mock latency.\r\n\r\n---\r\n\r\n## 12. Virtualization for large datasets\r\n\r\nFor more than a few thousand rows, enable row virtualization. For very\r\nwide grids (50+ columns) also enable column virtualization. Both are\r\nopt-in so small grids don't pay the cost.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid } from '@svgrid/grid'\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n virtualizeRows\r\n virtualizeColumns\r\n estimatedRowHeight={36}\r\n overscan={6}\r\n/>\r\n```\r\n\r\nFor full control (e.g. variable row heights, programmatic scroll),\r\nuse the headless virtualizer directly:\r\n\r\n```ts\r\nimport { createSvelteVirtualizer } from '@svgrid/grid'\r\n\r\nconst virtualizer = createSvelteVirtualizer({\r\n count: () => rows.length,\r\n getScrollElement: () => scrollRef,\r\n estimateSize: (index) => (rows[index].kind === 'header' ? 40 : 28),\r\n overscan: 6,\r\n})\r\n\r\n// Programmatic scroll:\r\nvirtualizer.scrollToIndex(75_432, { align: 'center' })\r\n```\r\n\r\nSee [`packages/grid/src/virtualization/`](../packages/grid/src/virtualization/)\r\nfor the full API.\r\n\r\n---\r\n\r\n## 13. Accessibility\r\n\r\nSvGrid implements the WAI-ARIA 1.2 grid pattern.\r\n\r\n- The root carries `role=\"grid\"`, an accessible name (set via\r\n `aria-label` or `aria-labelledby`), and `aria-rowcount` /\r\n `aria-colcount` reflecting the total - not just the visible window.\r\n- Rows carry `role=\"row\"` plus `aria-rowindex` accounting for the\r\n virtualized offset; cells carry `role=\"gridcell\"` and `aria-colindex`.\r\n- The active cell is always exactly one focusable element\r\n (roving `tabindex`); arrow keys move it.\r\n- Sort columns carry `aria-sort=\"ascending\" | \"descending\" | \"none\"`.\r\n- Sort and selection state changes are announced via an off-screen\r\n `aria-live` region the grid manages internally.\r\n\r\nIf you build your own header or toolbar, use the helpers in\r\n[`a11y.ts`](../packages/grid/src/a11y.ts) so your markup\r\nstays consistent with the contract:\r\n\r\n```ts\r\nimport {\r\n getGridRootA11yProps,\r\n getGridRowA11yProps,\r\n getGridCellA11yProps,\r\n getGridHeaderA11yProps,\r\n} from '@svgrid/grid'\r\n```\r\n\r\nThere is a contract test suite at\r\n[`a11y.contract.test.ts`](../packages/grid/src/a11y.contract.test.ts)\r\nthat exercises the public a11y guarantees - run it (`pnpm test`) when\r\nyou customize markup to be sure you haven't regressed the contract.\r\n\r\n---\r\n\r\n## 14. TypeScript notes\r\n\r\nMost APIs are generic over your row type. Define the row type once and\r\nflow it through:\r\n\r\n```ts\r\ntype Person = { id: string; firstName: string; age: number }\r\n\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName', header: 'First name' }, // āœ… key checked\r\n // { field: 'first_name', header: '…' }, // āœ— TS error\r\n]\r\n```\r\n\r\nThe first type parameter is the **feature set**. When you register\r\nfeatures, derive it once and reuse:\r\n\r\n```ts\r\nconst features = tableFeatures({\r\n rowSortingFeature,\r\n rowSelectionFeature,\r\n columnFilteringFeature,\r\n})\r\n\r\ntype Features = typeof features\r\n\r\nconst columns: ColumnDef<Features, Person>[] = [/* … */]\r\n```\r\n\r\nThis lets feature-specific column properties (like `filterFn`)\r\nauto-complete and type-check.\r\n\r\n---\r\n\r\n## 15. What's next\r\n\r\n### Core features\r\n\r\n- **[Examples gallery](https://svgrid.com/demos/)** - 50+ production-quality\r\n demos, from quick-start to 100k-row virtualization.\r\n- **[Column definitions](./help/columns/column-definitions.md)** - every\r\n property on `ColumnDef`.\r\n- **[Row sorting](./help/rows/row-sorting.md)** and the wider [rows topic index](./help/index.md#rows) -\r\n when to use which row model and the order they run in.\r\n- **[Tree rows (expand / collapse)](./help/rows/tree-rows.md)** - the\r\n flat-array + expanded-map pattern, connector lines, keyboard\r\n navigation, and lazy load on first expand.\r\n- **[Filter API](./help/filtering/filter-api.md)** - sort, filter,\r\n paginate, group, and aggregate locally or against your backend.\r\n- **[Tailwind integration](./help/tailwind.md)** - full list of CSS\r\n custom properties (`--sg-*`) and recipes for building your own theme.\r\n- **[Compare SvGrid with other Svelte data grids](https://svgrid.com/compare/)** -\r\n side-by-side feature matrix and when to pick which.\r\n\r\n### Enterprise features (`@svgrid/enterprise`)\r\n\r\nThe paid companion package augments your `SvGridApi` with one\r\n`installEnterprise(api)` call. Set a license key at app boot to remove the\r\n\"unlicensed\" watermark - every feature still runs without a key for\r\ndemos and evaluation.\r\n\r\n- **[Data export and printing](./help/export.md)** - Excel (xlsx), PDF,\r\n CSV, TSV, HTML, and a paginated print view. Defaults to the currently\r\n displayed rows so sort + filter + paginate carry through automatically.\r\n- **[Data import](./help/import.md)** - Excel (xlsx), CSV, TSV, and JSON\r\n with column mapping, per-row validation, and preview-before-commit. Auto-\r\n detects the format from the file extension or pasted text.\r\n- **[Pivot tables](./help/pivot.md)** - drag-and-drop Pivot Designer with\r\n Filters / Rows / Columns / Values zones, multi-level column headers,\r\n per-measure aggregator picker, pivot-aware sort. Built on the same\r\n engine; no special \"pivot mode\".\r\n- **[AI assistant](./help/ai.md)** - natural-language filter, smart fill,\r\n summarise, and classify, driven by a bring-your-own model adapter\r\n (`setAIProvider(fn)`). Ships with a deterministic `mockAIProvider` so\r\n the demo works without keys.\r\n\r\n### Getting help\r\n\r\n- File issues at the [project repository](https://github.com/sv-grid/sv-grid/issues).\r\n- Browse the [Help index](./help/index.md) for topic-oriented guides.\r\n- Use the [@svgrid/mcp](https://svgrid.com/mcp/) server\r\n to give your AI assistant accurate answers.\r\n- Read the source - it is small, well-commented, and meant to be read\r\n before opening a bug report.\r\n\r\n### License\r\n\r\n`@svgrid/grid` is published under the **MIT License**. Free for\r\ncommercial and personal use. The paid `@svgrid/enterprise` companion package\r\n(export, import, print, pivot, AI assistant) is governed by a separate\r\ncommercial license. See [LICENSE](../LICENSE) and\r\n[packages/enterprise/LICENSE](../packages/enterprise/LICENSE).\r\n"
2969
+ "markdown": "# Getting Started with SvGrid\r\n\r\nSvGrid is a modern, production-ready data grid for Svelte 5 - a headless\r\ncore engine paired with a Svelte render component\r\n(`<SvGrid>`). It scales from a 10-row read-only table to a virtualized\r\n100,000-row, 100-column editing surface with grouping, multi-column\r\nfiltering, server-side data, and full keyboard and screen-reader\r\nsupport.\r\n\r\nThis page walks you from `pnpm add` to a feature-complete grid. It is\r\nthe canonical entry point - every other page in the documentation\r\nassumes you've finished this one. Estimated reading time: 15 minutes.\r\n\r\n> **New here?** Two short companion reads:\r\n>\r\n> - [Why headless?](./why-headless.md) - the architecture decision\r\n> behind the `createSvGrid` core vs. the `<SvGrid>` renderer.\r\n> - [Tailwind integration](./help/tailwind.md) - how `--sg-*` custom\r\n> properties + Tailwind v4 + dark mode fit together.\r\n\r\n> `@svgrid/grid` is published under the **MIT License** - permissive\r\n> for commercial use, redistribution, and modification. The paid companion\r\n> `@svgrid/enterprise` (data export + print) ships under a separate commercial\r\n> license. See [LICENSE](../LICENSE) and\r\n> [packages/enterprise/LICENSE](../packages/enterprise/LICENSE).\r\n\r\n---\r\n\r\n## Contents\r\n\r\n1. [Your first grid in 60 seconds](#1-your-first-grid-in-60-seconds)\r\n2. [Install the package](#2-install-the-package)\r\n3. [Provide row data](#3-provide-row-data)\r\n4. [Define column definitions](#4-define-column-definitions)\r\n5. [Register features (row models)](#5-register-features-row-models)\r\n6. [Styling: theme, density, dark mode](#6-styling-theme-density-dark-mode)\r\n7. [Sizing the grid](#7-sizing-the-grid)\r\n8. [Custom cells with FlexRender](#8-custom-cells-with-flexrender)\r\n9. [Sorting, filtering, pagination](#9-sorting-filtering-pagination)\r\n10. [Selection, editing, keyboard](#10-selection-editing-keyboard)\r\n11. [Server-side data](#11-server-side-data)\r\n12. [Virtualization for large datasets](#12-virtualization-for-large-datasets)\r\n13. [Accessibility](#13-accessibility)\r\n14. [TypeScript notes](#14-typescript-notes)\r\n15. [What's next](#15-whats-next)\r\n\r\n---\r\n\r\n## 1. Your first grid in 60 seconds\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, type ColumnDef } from '@svgrid/grid'\r\n\r\n type Person = { firstName: string; age: number; status: string }\r\n\r\n const rows: Person[] = [\r\n { firstName: 'Ada', age: 36, status: 'active' },\r\n { firstName: 'Linus', age: 54, status: 'active' },\r\n { firstName: 'Grace', age: 85, status: 'inactive' },\r\n ]\r\n\r\n const columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName', header: 'First name' },\r\n { field: 'age', header: 'Age' },\r\n { field: 'status', header: 'Status' },\r\n ]\r\n</script>\r\n\r\n<SvGrid data={rows} columns={columns} />\r\n```\r\n\r\nThat's a complete, working grid. The rest of this page is about turning\r\nit into something you'd ship.\r\n\r\n---\r\n\r\n## 2. Install the package\r\n\r\nSvGrid is a single npm package. There is no peer dependency on a CSS\r\nframework - bring your own, or use the bundled stylesheet.\r\n\r\n```bash\r\n# pnpm (recommended)\r\npnpm add @svgrid/grid\r\n\r\n# npm\r\nnpm install @svgrid/grid\r\n\r\n# yarn\r\nyarn add @svgrid/grid\r\n```\r\n\r\n**Requirements.**\r\n\r\n- Svelte **5.x** (uses runes - `$state`, `$derived`, `$effect`).\r\n- TypeScript **5.4+** (optional but recommended).\r\n- Node **18+** for tooling.\r\n\r\nOnce installed, import the component, the features you want, and the\r\nmatching `ColumnDef` type:\r\n\r\n```ts\r\nimport {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n type ColumnDef,\r\n} from '@svgrid/grid'\r\n```\r\n\r\nThe bundle is tree-shakeable - features you don't import don't ship. The\r\ndefault render component (`<SvGrid>`) brings its own scoped CSS, so\r\nthere's no separate stylesheet to import. Re-theming happens via the\r\n`--sg-*` custom-property surface; see\r\n[Tailwind integration](./help/tailwind.md) for the full list.\r\n\r\n---\r\n\r\n## 3. Provide row data\r\n\r\nSvGrid is data-agnostic. The `data` prop is any\r\n`ReadonlyArray<TRow>` - a Svelte 5 `$state` array, a derived store, an\r\nSWR/React-query-style cache, the result of a `+page.ts` load function,\r\nor a plain literal.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid } from '@svgrid/grid'\r\n\r\n type Person = { id: string; firstName: string; age: number }\r\n\r\n // Reactive: pushing into `rows` updates the grid automatically.\r\n let rows = $state<Person[]>([\r\n { id: '1', firstName: 'Ada', age: 36 },\r\n { id: '2', firstName: 'Linus', age: 54 },\r\n ])\r\n\r\n function addRow() {\r\n rows.push({ id: crypto.randomUUID(), firstName: 'New', age: 0 })\r\n }\r\n</script>\r\n\r\n<button onclick={addRow}>Add row</button>\r\n<SvGrid data={rows} columns={columns} />\r\n```\r\n\r\n**Identity.** Today the wrapper uses the row's array index as its id.\r\nThat is fine for read-only data; if you mutate `rows`, prefer keeping\r\nthe same object references for rows that didn't change so selection\r\nand edit state line up. A `getRowId` prop on the wrapper is tracked in\r\n[Missing features](./help/missing-features.md) and supported by the\r\nheadless `createSvGrid` core today.\r\n\r\n**Immutability.** SvGrid never mutates your data. When you edit a cell\r\nthe grid emits an event; you decide whether to mutate in place or copy.\r\nSee [§10 - Editing](#10-selection-editing-keyboard).\r\n\r\n---\r\n\r\n## 4. Define column definitions\r\n\r\nA column definition tells SvGrid how to read a value out of a row, how\r\nto render it, and which features apply to it.\r\n\r\n```ts\r\nimport type { ColumnDef } from '@svgrid/grid'\r\n\r\ntype Person = {\r\n id: string\r\n firstName: string\r\n lastName: string\r\n age: number\r\n joinedAt: string // ISO date\r\n salary: number\r\n active: boolean\r\n}\r\n\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n // Simple accessor by key\r\n { field: 'firstName', header: 'First name' },\r\n\r\n // Computed accessor\r\n {\r\n id: 'fullName',\r\n header: 'Full name',\r\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\r\n },\r\n\r\n // Numeric with locale-aware formatting\r\n {\r\n field: 'age',\r\n header: 'Age',\r\n format: { type: 'number', options: { maximumFractionDigits: 0 } },\r\n },\r\n\r\n // Date with explicit pattern\r\n {\r\n field: 'joinedAt',\r\n header: 'Joined',\r\n format: { type: 'date', pattern: 'y-m-d' },\r\n },\r\n\r\n // Currency\r\n {\r\n field: 'salary',\r\n header: 'Salary',\r\n format: { type: 'currency', currency: 'USD' },\r\n },\r\n\r\n // Boolean rendered as a checkbox\r\n {\r\n field: 'active',\r\n header: 'Active',\r\n editorType: 'checkbox',\r\n },\r\n]\r\n```\r\n\r\n**Common properties.**\r\n\r\n| Property | Purpose |\r\n| --- | --- |\r\n| `field` | Reads `row[key]`. |\r\n| `fieldFn` | Computes the value from the row. |\r\n| `id` | Stable column id (required if you use `fieldFn`). |\r\n| `header` | String or render snippet for the header. |\r\n| `footer` | String or render snippet for the footer row. |\r\n| `cell` | Render snippet/component for the body cell. |\r\n| `format` | Locale-aware formatter (`number`, `currency`, `percent`, `date`). |\r\n| `formatter` | Function for one-off custom value formatting. |\r\n| `editorType` | Inline editor: `text` \\| `number` \\| `checkbox` \\| `date` \\| `datetime`. |\r\n| `width` | Initial column width in pixels (default `columnWidth` prop). |\r\n| `align` | Header + body alignment: `'left'` \\| `'right'` \\| `'center'`. Inferred from `editorType` when omitted. |\r\n| `columns` | Child column defs (for column groups). |\r\n\r\nSorting / filtering / grouping are toggled per-grid via the registered\r\nfeatures - there is no per-column `enableSorting` / `enableColumnFilter`\r\nflag yet; those entries are in [Missing features](./help/missing-features.md).\r\n\r\nSee [`packages/grid/src/core.ts`](../packages/grid/src/core.ts)\r\nfor the full type.\r\n\r\n---\r\n\r\n## 5. Register features (row models)\r\n\r\nThe grid engine is feature-gated. Out of the box you get the **core row\r\nmodel** (the rows in their original order). To enable sorting,\r\nfiltering, grouping, expansion, pagination, or selection you opt in\r\nwith `tableFeatures(...)` and the matching `create*RowModel` factory.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n type ColumnDef,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination={true}\r\n pageSize={25}\r\n/>\r\n```\r\n\r\n**Rule of thumb.** Only register the features you use. The wrapper wires\r\nthe matching row-model pipeline (core → filtered → sorted → grouped →\r\nexpanded) for you and exposes the user-facing toggles via props\r\n(`showPagination`, `filterMode`, `showRowSelection`, …). If you need the\r\nheadless pipeline directly - e.g. a custom renderer - drop down to\r\n`createSvGrid` from the same package; see [Why headless?](./why-headless.md).\r\n\r\n| Feature | Factory | What it does |\r\n| --- | --- | --- |\r\n| `rowSortingFeature` | `createSortedRowModel` | Click headers to sort; shift-click for multi-sort. |\r\n| `columnFilteringFeature` | `createFilteredRowModel` | Per-column filters with built-in `filterFns`. |\r\n| `rowPaginationFeature` | `createPaginatedRowModel` | Page slicing + footer state. |\r\n| `rowSelectionFeature` | - | Row checkboxes, range selection, headless API. |\r\n| `columnGroupingFeature` | `createGroupedRowModel` | Group-by-column + aggregators. |\r\n| `rowExpandingFeature` | `createExpandedRowModel` | Tree / master-detail expansion. |\r\n\r\n---\r\n\r\n## 6. Styling: theme, density, dark mode\r\n\r\nSvGrid's render component (`<SvGrid>`) ships its own scoped styles. You\r\nre-theme it by declaring `--sg-*` custom properties at any level above\r\nthe grid - the included light, dark, and high-contrast palettes in the\r\n[`10-custom-cells-and-themes`](../examples/src/demos/10-custom-cells-and-themes.svelte)\r\ndemo are themselves just three sets of these tokens applied via\r\n`style=\"--sg-bg: …; --sg-fg: …; …\"`.\r\n\r\n**Customising tokens.** Override at any level - `:root`, a wrapper, or\r\ndirectly on `<SvGrid>`.\r\n\r\n```css\r\n:root {\r\n --sg-row-height: 36px;\r\n --sg-header-bg: #f6f7f9;\r\n --sg-header-fg: #1f2933;\r\n --sg-row-hover-bg: #eef2ff;\r\n --sg-selection-bg: #dbeafe;\r\n --sg-border: #e5e7eb;\r\n --sg-focus-ring: 0 0 0 2px #2563eb;\r\n --sg-font: 'Inter', system-ui, sans-serif;\r\n}\r\n\r\n@media (prefers-color-scheme: dark) {\r\n :root {\r\n --sg-header-bg: #0f172a;\r\n --sg-header-fg: #f1f5f9;\r\n --sg-row-hover-bg: #1e293b;\r\n --sg-border: #334155;\r\n }\r\n}\r\n```\r\n\r\n**Density.** The default theme reads `--sg-row-height`; flip it to\r\n`28px` for compact mode and `48px` for comfortable. Density changes are\r\napplied without remounting the virtualizer.\r\n\r\n**Reduced motion.** Sort animations and expand transitions respect\r\n`prefers-reduced-motion: reduce` automatically.\r\n\r\n---\r\n\r\n## 7. Sizing the grid\r\n\r\n`<SvGrid>` fills its parent. Give it a height and it scrolls - without\r\none, it expands to its content and never virtualises.\r\n\r\n```svelte\r\n<!-- Fixed: 600px tall, full width. The typical choice. -->\r\n<div style=\"height: 600px;\">\r\n <SvGrid data={rows} columns={columns} />\r\n</div>\r\n\r\n<!-- Flexible: fills the viewport minus header/footer. -->\r\n<div class=\"grid-shell\">\r\n <SvGrid data={rows} columns={columns} />\r\n</div>\r\n\r\n<style>\r\n .grid-shell {\r\n height: calc(100dvh - 4rem);\r\n }\r\n</style>\r\n```\r\n\r\n**Auto-height (small datasets only).** For grids with fewer than ~200\r\nrows you can let the grid grow to its content:\r\n\r\n```svelte\r\n<SvGrid data={rows} columns={columns} domLayout=\"autoHeight\" />\r\n```\r\n\r\nAuto-height disables row virtualization. Don't use it for large data.\r\n\r\n---\r\n\r\n## 8. Custom cells with FlexRender\r\n\r\nFor anything beyond a stringified value, render with `FlexRender`,\r\n`renderComponent`, or `renderSnippet`.\r\n\r\n### As a Svelte snippet\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { renderSnippet, type ColumnDef } from '@svgrid/grid'\r\n</script>\r\n\r\n{#snippet StatusCell({ value }: { value: string })}\r\n <span class=\"pill pill-{value}\">{value}</span>\r\n{/snippet}\r\n\r\n<script lang=\"ts\">\r\n const columns: ColumnDef<{}, Person>[] = [\r\n {\r\n field: 'status',\r\n header: 'Status',\r\n cell: renderSnippet(StatusCell, (ctx) => ({ value: ctx.getValue() as string })),\r\n },\r\n ]\r\n</script>\r\n```\r\n\r\n### As a Svelte component\r\n\r\n```ts\r\nimport StatusBadge from './StatusBadge.svelte'\r\nimport { renderComponent } from '@svgrid/grid'\r\n\r\nconst columns = [\r\n {\r\n field: 'status',\r\n header: 'Status',\r\n cell: renderComponent(StatusBadge, (ctx) => ({ status: ctx.getValue() })),\r\n },\r\n]\r\n```\r\n\r\n`renderComponent` and `renderSnippet` both receive a\r\n`CellContext` so you can read sibling values, mutate state, or call\r\nback into the grid via `ctx.table`.\r\n\r\n---\r\n\r\n## 9. Sorting, filtering, pagination\r\n\r\nOnce their features are registered (see §5) the UI affordances appear\r\nautomatically. The state is controllable.\r\n\r\n**Quick way - capability shortcuts.** Every capability is off by default;\r\nthe fastest way to opt in is a boolean shortcut prop, no feature constants\r\nrequired. `sortable` and `filterable` inject the matching feature for you;\r\n`editable`, `groupable`, and `pageable` alias `enableInlineEditing`,\r\n`showGroupingControls`, and `showPagination`:\r\n\r\n```svelte\r\n<SvGrid data={rows} columns={columns} sortable filterable editable groupable pageable />\r\n```\r\n\r\nReach for the explicit `features` set + fine-grained props below when you\r\nneed more control (filter mode, page size, per-column opt-outs).\r\n\r\n### Uncontrolled (the default)\r\n\r\nThe wrapper owns sort, filter, pagination, selection, and expansion\r\nstate by default. Set the initial page size and which filter UI to\r\nshow via props:\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination={true}\r\n pageSize={50}\r\n filterMode=\"menu\"\r\n/>\r\n```\r\n\r\n### Observable (callbacks fire when state changes)\r\n\r\nThe wrapper still owns the state, but emits callbacks on every change.\r\nUse this when an outside piece of UI needs to react (a \"X rows\r\nselected\" pill, a router that syncs sort to the URL, a server fetch).\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\r\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n onSortingChange={(next) => (sorting = next)}\r\n onFiltersChange={(next) => (filters = next.columns)}\r\n/>\r\n```\r\n\r\n### External (you own row ordering / filtering)\r\n\r\nFor server-side data or tree-structured data the wrapper records the\r\nsort + filter UI state but does **not** re-order the rows - you do.\r\nPair `externalSort` / `externalFilter` with the callbacks above:\r\n\r\n```svelte\r\n<SvGrid\r\n data={preFilteredRows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n externalSort={true}\r\n externalFilter={true}\r\n onSortingChange={(next) => fetchPage({ sort: next, page: 0 })}\r\n onFiltersChange={(next) => fetchPage({ filters: next.columns, page: 0 })}\r\n/>\r\n```\r\n\r\nFor Excel-style filter operators and the active-filter chip UI, see\r\n[`applyExcelFilter`](../packages/grid/src/filtering/excel-filters.ts).\r\n\r\n---\r\n\r\n## 10. Selection, editing, keyboard\r\n\r\n### Row selection\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let selected = $state<Person[]>([])\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n selectionMode=\"row\"\r\n showRowSelection={true}\r\n onRowSelectionChange={(_state, rows) => (selected = rows)}\r\n/>\r\n\r\n{#if selected.length}\r\n <p>{selected.length} selected</p>\r\n{/if}\r\n```\r\n\r\n`selectionMode` is the umbrella prop: `'row'` shows the checkbox\r\ncolumn, `'cell'` enables click-and-drag range selection,\r\n`'both'` (default) enables both, `'none'` disables both. The\r\n`onRowSelectionChange` callback receives the selection record AND the\r\nmaterialised row array.\r\n\r\n### Cell editing\r\n\r\nSet `editorType` on each editable column. The grid handles entry,\r\ncommit, and cancel; you handle persistence.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n function handleCellEdit(event: {\r\n rowId: string\r\n columnId: string\r\n value: unknown\r\n }) {\r\n const row = rows.find((r) => r.id === event.rowId)\r\n if (!row) return\r\n ;(row as Record<string, unknown>)[event.columnId] = event.value\r\n }\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n getRowId={(row) => row.id}\r\n onCellValueChange={handleCellEdit}\r\n/>\r\n```\r\n\r\n### Keyboard\r\n\r\nThe grid follows the WAI-ARIA grid pattern:\r\n\r\n| Keys | Action |\r\n| --- | --- |\r\n| `←` `↑` `→` `↓` | Move active cell |\r\n| `Home` / `End` | First / last column of the row |\r\n| `Ctrl+Home` / `Ctrl+End` | First / last cell of the grid |\r\n| `PageUp` / `PageDown` | Move one viewport |\r\n| `Shift + <move>` | Extend cell-range selection |\r\n| `Space` | Toggle row selection (when selection enabled) |\r\n| `Enter` / `F2` | Begin editing the active cell |\r\n| `Esc` | Cancel edit / clear selection |\r\n| `Ctrl/Cmd + C` | Copy selection as TSV |\r\n| `Ctrl/Cmd + V` | Paste TSV into selection |\r\n\r\nIf you implement your own header or toolbar, route keys through\r\n`getKeyboardIntent` and `getNextActiveCell` so behaviour stays\r\nconsistent.\r\n\r\n---\r\n\r\n## 11. Server-side data\r\n\r\nFor datasets that don't fit in memory, drive the grid from the server.\r\nThe pattern is: turn the wrapper's `onSortingChange` /\r\n`onFiltersChange` callbacks into a query, fetch, hand the page back as\r\n`data`, and use the `externalSort` + `externalFilter` props so the grid\r\ndoesn't try to re-order rows it didn't fetch.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, tableFeatures, rowSortingFeature,\r\n columnFilteringFeature } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\r\n\r\n let sort = $state<Array<{ id: string; desc: boolean }>>([])\r\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\r\n let page = $state(0)\r\n const pageSize = 50\r\n\r\n let rows = $state<Person[]>([])\r\n let total = $state(0)\r\n let loading = $state(false)\r\n let controller: AbortController | null = null\r\n\r\n async function load() {\r\n controller?.abort()\r\n controller = new AbortController()\r\n loading = true\r\n try {\r\n const res = await fetch('/api/people?' + new URLSearchParams({\r\n sort: JSON.stringify(sort),\r\n filters: JSON.stringify(filters),\r\n page: String(page),\r\n size: String(pageSize),\r\n }), { signal: controller.signal })\r\n const body = await res.json()\r\n rows = body.rows\r\n total = body.total\r\n } catch (err) {\r\n if ((err as Error).name !== 'AbortError') throw err\r\n } finally {\r\n loading = false\r\n }\r\n }\r\n\r\n $effect(() => { sort; filters; page; load() })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n externalSort={true}\r\n externalFilter={true}\r\n showPagination={false}\r\n onSortingChange={(next) => { sort = next; page = 0 }}\r\n onFiltersChange={(next) => { filters = next.columns; page = 0 }}\r\n/>\r\n\r\n<nav>\r\n <button onclick={() => (page = Math.max(0, page - 1))}\r\n disabled={page === 0 || loading}>‹ Prev</button>\r\n <span>Page {page + 1} of {Math.ceil(total / pageSize)}</span>\r\n <button onclick={() => (page = page + 1)}\r\n disabled={(page + 1) * pageSize >= total || loading}>Next ›</button>\r\n</nav>\r\n\r\n{#if loading}<div class=\"overlay\">Loading…</div>{/if}\r\n```\r\n\r\nThe `external*` props tell the grid not to re-derive that dimension\r\nlocally - the data you pass in is already the answer. Pagination above\r\nis hand-rolled so total-row-count and \"show next page\" stay in your\r\ncontrol; if a built-in pager is enough, leave `showPagination={true}`\r\non and the wrapper will page the local `rows` array (which, in this\r\nserver-side pattern, only ever holds one page anyway).\r\n\r\nSee the [`09-server-side` demo](../examples/src/demos/09-server-side.svelte)\r\nfor a complete runnable version with debounce, abort wiring, and a\r\n60 ms mock latency.\r\n\r\n---\r\n\r\n## 12. Virtualization for large datasets\r\n\r\nFor more than a few thousand rows, enable row virtualization. For very\r\nwide grids (50+ columns) also enable column virtualization. Both are\r\nopt-in so small grids don't pay the cost.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid } from '@svgrid/grid'\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n virtualizeRows\r\n virtualizeColumns\r\n estimatedRowHeight={36}\r\n overscan={6}\r\n/>\r\n```\r\n\r\nFor full control (e.g. variable row heights, programmatic scroll),\r\nuse the headless virtualizer directly:\r\n\r\n```ts\r\nimport { createSvelteVirtualizer } from '@svgrid/grid'\r\n\r\nconst virtualizer = createSvelteVirtualizer({\r\n count: () => rows.length,\r\n getScrollElement: () => scrollRef,\r\n estimateSize: (index) => (rows[index].kind === 'header' ? 40 : 28),\r\n overscan: 6,\r\n})\r\n\r\n// Programmatic scroll:\r\nvirtualizer.scrollToIndex(75_432, { align: 'center' })\r\n```\r\n\r\nSee [`packages/grid/src/virtualization/`](../packages/grid/src/virtualization/)\r\nfor the full API.\r\n\r\n---\r\n\r\n## 13. Accessibility\r\n\r\nSvGrid implements the WAI-ARIA 1.2 grid pattern.\r\n\r\n- The root carries `role=\"grid\"`, an accessible name (set via\r\n `aria-label` or `aria-labelledby`), and `aria-rowcount` /\r\n `aria-colcount` reflecting the total - not just the visible window.\r\n- Rows carry `role=\"row\"` plus `aria-rowindex` accounting for the\r\n virtualized offset; cells carry `role=\"gridcell\"` and `aria-colindex`.\r\n- The active cell is always exactly one focusable element\r\n (roving `tabindex`); arrow keys move it.\r\n- Sort columns carry `aria-sort=\"ascending\" | \"descending\" | \"none\"`.\r\n- Sort and selection state changes are announced via an off-screen\r\n `aria-live` region the grid manages internally.\r\n\r\nIf you build your own header or toolbar, use the helpers in\r\n[`a11y.ts`](../packages/grid/src/a11y.ts) so your markup\r\nstays consistent with the contract:\r\n\r\n```ts\r\nimport {\r\n getGridRootA11yProps,\r\n getGridRowA11yProps,\r\n getGridCellA11yProps,\r\n getGridHeaderA11yProps,\r\n} from '@svgrid/grid'\r\n```\r\n\r\nThere is a contract test suite at\r\n[`a11y.contract.test.ts`](../packages/grid/src/a11y.contract.test.ts)\r\nthat exercises the public a11y guarantees - run it (`pnpm test`) when\r\nyou customize markup to be sure you haven't regressed the contract.\r\n\r\n---\r\n\r\n## 14. TypeScript notes\r\n\r\nMost APIs are generic over your row type. Define the row type once and\r\nflow it through:\r\n\r\n```ts\r\ntype Person = { id: string; firstName: string; age: number }\r\n\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName', header: 'First name' }, // āœ… key checked\r\n // { field: 'first_name', header: '…' }, // āœ— TS error\r\n]\r\n```\r\n\r\nThe first type parameter is the **feature set**. When you register\r\nfeatures, derive it once and reuse:\r\n\r\n```ts\r\nconst features = tableFeatures({\r\n rowSortingFeature,\r\n rowSelectionFeature,\r\n columnFilteringFeature,\r\n})\r\n\r\ntype Features = typeof features\r\n\r\nconst columns: ColumnDef<Features, Person>[] = [/* … */]\r\n```\r\n\r\nThis lets feature-specific column properties (like `filterFn`)\r\nauto-complete and type-check.\r\n\r\n---\r\n\r\n## 15. What's next\r\n\r\n### Core features\r\n\r\n- **[Examples gallery](https://svgrid.com/demos/)** - 50+ production-quality\r\n demos, from quick-start to 100k-row virtualization.\r\n- **[Column definitions](./help/columns/column-definitions.md)** - every\r\n property on `ColumnDef`.\r\n- **[Row sorting](./help/rows/row-sorting.md)** and the wider [rows topic index](./help/index.md#rows) -\r\n when to use which row model and the order they run in.\r\n- **[Tree rows (expand / collapse)](./help/rows/tree-rows.md)** - the\r\n flat-array + expanded-map pattern, connector lines, keyboard\r\n navigation, and lazy load on first expand.\r\n- **[Filter API](./help/filtering/filter-api.md)** - sort, filter,\r\n paginate, group, and aggregate locally or against your backend.\r\n- **[Tailwind integration](./help/tailwind.md)** - full list of CSS\r\n custom properties (`--sg-*`) and recipes for building your own theme.\r\n- **[Compare SvGrid with other Svelte data grids](https://svgrid.com/compare/)** -\r\n side-by-side feature matrix and when to pick which.\r\n\r\n### Enterprise features (`@svgrid/enterprise`)\r\n\r\nThe paid companion package augments your `SvGridApi` with one\r\n`installEnterprise(api)` call. Set a license key at app boot to remove the\r\n\"unlicensed\" watermark - every feature still runs without a key for\r\ndemos and evaluation.\r\n\r\n- **[Data export and printing](./help/export.md)** - Excel (xlsx), PDF,\r\n CSV, TSV, HTML, and a paginated print view. Defaults to the currently\r\n displayed rows so sort + filter + paginate carry through automatically.\r\n- **[Data import](./help/import.md)** - Excel (xlsx), CSV, TSV, and JSON\r\n with column mapping, per-row validation, and preview-before-commit. Auto-\r\n detects the format from the file extension or pasted text.\r\n- **[Pivot tables](./help/pivot.md)** - drag-and-drop Pivot Designer with\r\n Filters / Rows / Columns / Values zones, multi-level column headers,\r\n per-measure aggregator picker, pivot-aware sort. Built on the same\r\n engine; no special \"pivot mode\".\r\n- **[AI assistant](./help/ai.md)** - natural-language filter, smart fill,\r\n summarise, and classify, driven by a bring-your-own model adapter\r\n (`setAIProvider(fn)`). Ships with a deterministic `mockAIProvider` so\r\n the demo works without keys.\r\n\r\n### Getting help\r\n\r\n- File issues at the [project repository](https://github.com/sv-grid/sv-grid/issues).\r\n- Browse the [Help index](./help/index.md) for topic-oriented guides.\r\n- Use the [@svgrid/mcp](https://svgrid.com/mcp/) server\r\n to give your AI assistant accurate answers.\r\n- Read the source - it is small, well-commented, and meant to be read\r\n before opening a bug report.\r\n\r\n### License\r\n\r\n`@svgrid/grid` is published under the **MIT License**. Free for\r\ncommercial and personal use. The paid `@svgrid/enterprise` companion package\r\n(export, import, print, pivot, AI assistant) is governed by a separate\r\ncommercial license. See [LICENSE](../LICENSE) and\r\n[packages/enterprise/LICENSE](../packages/enterprise/LICENSE).\r\n"
2949
2970
  },
2950
2971
  {
2951
2972
  "slug": "getting-started",
@@ -2975,13 +2996,13 @@ export const docs = [
2975
2996
  "slug": "getting-started/4-features",
2976
2997
  "path": "docs/getting-started/4-features.md",
2977
2998
  "title": "4. Features",
2978
- "markdown": "# 4. Features\n\n> Step 4 of 6 Ā· [← Data and columns](./3-data-and-columns.md) Ā· [Next: Theme and density →](./5-theme-and-density.md)\n\nThe grid engine is **feature-gated**. Out of the box you get the core\nrow model (rows in their original order). To enable sorting, filtering,\ngrouping, pagination, expansion, or selection, register the matching\nfeature; the wrapper wires the matching row-model factory for you.\n\n![The engine ships the core row model in original order; you opt into features that wire into the SvGrid component.](/docs-media/grid-feature-gating.svg)\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n type ColumnDef,\n } from '@svgrid/grid'\n\n const features = tableFeatures({\n rowSortingFeature,\n columnFilteringFeature,\n rowSelectionFeature,\n })\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n showPagination={true}\n pageSize={25}\n/>\n```\n\nThe wrapper handles the user-facing toggles via plain props. If you\nneed the headless pipeline directly - say, a custom renderer - drop\ndown to `createSvGrid` from the same package. See\n[Why headless?](../why-headless.md).\n\n## Capability shortcuts (the quick way)\n\nEvery capability is **off by default** - a bare `<SvGrid>` is a plain,\nread-only table. The fastest way to opt in is a set of boolean shortcut\nprops. No `tableFeatures({ … })` import, no feature constants:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n sortable\n filterable\n editable\n groupable\n pageable\n/>\n```\n\n| Shortcut | Turns on | Equivalent to |\n| ------------ | ------------------------------------------------ | ------------------------------------------ |\n| `sortable` | Click headers to sort | injects `rowSortingFeature` |\n| `filterable` | Per-column filter menu | injects `columnFilteringFeature` |\n| `editable` | Inline cell editing (needs `editorType` columns) | `enableInlineEditing` |\n| `groupable` | \"Group by this column\" in the column menu | `showGroupingControls` |\n| `pageable` | Pagination footer | `showPagination` |\n\nEach shortcut is an override: omit it (or set `false`) to leave the\ncapability off; set it `true` to opt in. They compose with the\nfine-grained props and the `features` set below - reach for those when\nyou need finer control (e.g. `filterMode`, `pageSize`, per-column\n`sortable: false`). See the live [Shortcut config](https://sv-grid.com/demos/135-shortcut-config) demo.\n\n## The feature catalogue\n\n| Feature | What it enables | Doc |\n| ------------------------ | --------------------------------------------------------- | -------------------------------------------------- |\n| `rowSortingFeature` | Click headers to sort; Shift-click for multi-sort. | [Row sorting](../help/rows/row-sorting.md) |\n| `columnFilteringFeature` | Per-column filter menu + filter row + global search. | [Filter overview](../help/filtering/overview.md) |\n| `rowPaginationFeature` | Page slicing + footer with page-size selector. | [Row pagination](../help/rows/row-pagination.md) |\n| `rowSelectionFeature` | Checkbox column + Shift / Ctrl multi-select. | [Row selection](../help/rows/styling-rows.md) |\n| `columnGroupingFeature` | Group-by-column + aggregated footer summaries. | [Grouping & aggregation](../help/grouping-aggregation.md) |\n| `rowExpandingFeature` | Tree / master-detail expand-collapse. | [Tree rows](../help/rows/tree-rows.md) |\n\n**Rule of thumb:** only register the features you use. Each one ships\nabout 1-2 KB gzipped and adds a small per-update cost.\n\n## The three operating modes\n\nThere's one decision per dimension (sort, filter): **uncontrolled**\n(default), **observable** (callbacks), or **external** (you own the row\nordering).\n\n### Uncontrolled (the default)\n\nThe wrapper owns the state. Pass the start config via props:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n showPagination={true}\n pageSize={50}\n filterMode=\"menu\"\n/>\n```\n\n### Observable - callbacks fire on every change\n\nThe wrapper still owns the state, but emits callbacks. Use this when\nsomething outside the grid needs to react (a \"X rows selected\" pill,\nURL sync, a server fetch).\n\n```svelte\n<script lang=\"ts\">\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\n</script>\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n filterMode=\"menu\"\n onSortingChange={(next) => (sorting = next)}\n onFiltersChange={(next) => (filters = next.columns)}\n/>\n```\n\n### External - you own the row ordering\n\nFor server-side data or tree data the wrapper records the sort + filter\nUI state but does **not** re-order the rows - you do. See\n[Going to production §1](./6-going-to-production.md#1-server-side-data).\n\n## Selection + editing\n\nBoth are off by default. Two top-level umbrella props turn them on:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n selectionMode=\"both\"\n showRowSelection={true}\n enableInlineEditing={true}\n enableCellSelection={true}\n onRowSelectionChange={(state, selectedRows) => /* … */}\n onCellValueChange={(event) => /* event: { rowIndex, columnId, oldValue, newValue, row } */}\n/>\n```\n\n`selectionMode` choices:\n\n- `'row'` - checkbox column only\n- `'cell'` - click-and-drag range selection only\n- `'both'` - both (default)\n- `'none'` - both off\n\nCell editing requires `editorType` on the columns you want editable.\nSee [Editing overview](../help/editing/overview.md).\n\n## Keyboard map\n\n| Action | Keys |\n| --------------------- | ------------------------------------- |\n| Move active cell | Arrow keys |\n| First / last in row | Home / End |\n| First / last in grid | Ctrl + Home / Ctrl + End |\n| Move by viewport | Page Up / Page Down |\n| Extend range | Shift + arrows / Shift + Home / End |\n| Start editing | Enter, F2, or double-click |\n| Commit edit | Enter, Tab |\n| Cancel edit | Esc |\n| Toggle row selection | Space |\n| Copy / paste range | Ctrl/Cmd + C / V (TSV) |\n\nThe full a11y model is in [Accessibility](../help/accessibility.md).\n"
2999
+ "markdown": "# 4. Features\r\n\r\n> Step 4 of 6 · [← Data and columns](./3-data-and-columns.md) · [Next: Theme and density →](./5-theme-and-density.md)\r\n\r\nEvery capability is **off by default** - a bare `<SvGrid>` is a plain,\r\nread-only table. Opt in with boolean props. No imports, no feature\r\nconstants:\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n sortable\r\n filterable\r\n editable\r\n groupable\r\n pageable\r\n/>\r\n```\r\n\r\n| Shortcut | Turns on | Equivalent to |\r\n| ------------ | ------------------------------------------------ | ------------------------------------------ |\r\n| `sortable` | Click headers to sort | injects `rowSortingFeature` |\r\n| `filterable` | Per-column filter menu | injects `columnFilteringFeature` |\r\n| `editable` | Inline cell editing (needs `editorType` columns) | `enableInlineEditing` |\r\n| `groupable` | \"Group by this column\" in the column menu | `showGroupingControls` |\r\n| `pageable` | Pagination footer | `showPagination` |\r\n\r\nEach shortcut is an override: omit it (or set `false`) to leave the\r\ncapability off; set it `true` to opt in. See the live\r\n[Shortcut config](https://svgrid.com/demos/135-shortcut-config/) demo.\r\n\r\n**For most grids this is the whole story** - skip to\r\n[Theme and density](./5-theme-and-density.md). The rest of this page is the\r\nexplicit form underneath, worth reading when you want finer control.\r\n\r\n## The explicit form: registering features\r\n\r\nThe engine is feature-gated: out of the box you get the core row model (rows\r\nin their original order), and each capability is a feature you register. The\r\nshortcut props above just inject these for you.\r\n\r\n![The engine ships the core row model in original order; you opt into features that wire into the SvGrid component.](/docs-media/grid-feature-gating.svg)\r\n\r\nRegister them yourself when you need the fine-grained props alongside -\r\n`filterMode`, `pageSize`, per-column `sortable: false`:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n type GridColumns,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n showPagination={true}\r\n pageSize={25}\r\n/>\r\n```\r\n\r\nIf you need the headless pipeline directly - say, a custom renderer - drop\r\ndown to `createSvGrid` from the same package. See\r\n[Why headless?](../why-headless.md).\r\n\r\n## The feature catalogue\r\n\r\n| Feature | What it enables | Doc |\r\n| ------------------------ | --------------------------------------------------------- | -------------------------------------------------- |\r\n| `rowSortingFeature` | Click headers to sort; Shift-click for multi-sort. | [Row sorting](../help/rows/row-sorting.md) |\r\n| `columnFilteringFeature` | Per-column filter menu + filter row + global search. | [Filter overview](../help/filtering/overview.md) |\r\n| `rowPaginationFeature` | Page slicing + footer with page-size selector. | [Row pagination](../help/rows/row-pagination.md) |\r\n| `rowSelectionFeature` | Checkbox column + Shift / Ctrl multi-select. | [Row selection](../help/rows/styling-rows.md) |\r\n| `columnGroupingFeature` | Group-by-column + aggregated footer summaries. | [Grouping & aggregation](../help/grouping-aggregation.md) |\r\n| `rowExpandingFeature` | Tree / master-detail expand-collapse. | [Tree rows](../help/rows/tree-rows.md) |\r\n\r\n**Rule of thumb:** only register the features you use. Each one ships\r\nabout 1-2 KB gzipped and adds a small per-update cost.\r\n\r\n## The three operating modes\r\n\r\nThere's one decision per dimension (sort, filter): **uncontrolled**\r\n(default), **observable** (callbacks), or **external** (you own the row\r\nordering).\r\n\r\n### Uncontrolled (the default)\r\n\r\nThe wrapper owns the state. Pass the start config via props:\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination={true}\r\n pageSize={50}\r\n filterMode=\"menu\"\r\n/>\r\n```\r\n\r\n### Observable - callbacks fire on every change\r\n\r\nThe wrapper still owns the state, but emits callbacks. Use this when\r\nsomething outside the grid needs to react (a \"X rows selected\" pill,\r\nURL sync, a server fetch).\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let sorting = $state<Array<{ id: string; desc: boolean }>>([])\r\n let filters = $state<Array<{ id: string; operator: string; value: string }>>([])\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n filterMode=\"menu\"\r\n onSortingChange={(next) => (sorting = next)}\r\n onFiltersChange={(next) => (filters = next.columns)}\r\n/>\r\n```\r\n\r\n### External - you own the row ordering\r\n\r\nFor server-side data or tree data the wrapper records the sort + filter\r\nUI state but does **not** re-order the rows - you do. See\r\n[Going to production §1](./6-going-to-production.md#1-server-side-data).\r\n\r\n## Selection + editing\r\n\r\nBoth are off by default. Two top-level umbrella props turn them on:\r\n\r\n```svelte\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n selectionMode=\"both\"\r\n showRowSelection={true}\r\n enableInlineEditing={true}\r\n enableCellSelection={true}\r\n onRowSelectionChange={(state, selectedRows) => /* … */}\r\n onCellValueChange={(event) => /* event: { rowIndex, columnId, oldValue, newValue, row } */}\r\n/>\r\n```\r\n\r\n`selectionMode` choices:\r\n\r\n- `'row'` - checkbox column only\r\n- `'cell'` - click-and-drag range selection only\r\n- `'both'` - both (default)\r\n- `'none'` - both off\r\n\r\nCell editing requires `editorType` on the columns you want editable.\r\nSee [Editing overview](../help/editing/overview.md).\r\n\r\n## Keyboard map\r\n\r\n| Action | Keys |\r\n| --------------------- | ------------------------------------- |\r\n| Move active cell | Arrow keys |\r\n| First / last in row | Home / End |\r\n| First / last in grid | Ctrl + Home / Ctrl + End |\r\n| Move by viewport | Page Up / Page Down |\r\n| Extend range | Shift + arrows / Shift + Home / End |\r\n| Start editing | Enter, F2, or double-click |\r\n| Commit edit | Enter, Tab |\r\n| Cancel edit | Esc |\r\n| Toggle row selection | Space |\r\n| Copy / paste range | Ctrl/Cmd + C / V (TSV) |\r\n\r\nThe full a11y model is in [Accessibility](../help/accessibility.md).\r\n"
2979
3000
  },
2980
3001
  {
2981
3002
  "slug": "getting-started/5-theme-and-density",
2982
3003
  "path": "docs/getting-started/5-theme-and-density.md",
2983
3004
  "title": "5. Theme and density",
2984
- "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\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-row-height` | Row height |\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\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\nTwo ways:\n\n1. **Set `rowHeight` on `<SvGrid>`**. Numeric, in pixels. Drives the\n row height and the active-cell hit box.\n\n ```svelte\n <SvGrid {data} {columns} features={features} rowHeight={28} />\n ```\n\n2. **Override `--sg-row-height` on a wrapper.** Same effect, with the\n token shape if you'd rather express density in CSS.\n\n ```css\n .compact { --sg-row-height: 28px; }\n .comfortable { --sg-row-height: 48px; }\n ```\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 : 36,\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"
3005
+ "markdown": "# 5. Theme and density\r\n\r\n> Step 5 of 6 Ā· [← Features](./4-features.md) Ā· [Next: Going to production →](./6-going-to-production.md)\r\n\r\n## Start with a preset\r\n\r\nBefore hand-writing any tokens: 20 design-system presets ship with the package,\r\neach a single stylesheet with a full light + dark palette. One import re-themes\r\nthe whole grid.\r\n\r\n```ts\r\nimport '@svgrid/grid/themes/shadcn.css'\r\n```\r\n\r\nAvailable: `ember` (SvGrid's own look), `shadcn`, `tailwind`, `material`,\r\n`fluent`, `carbon`, `antd`, `bootstrap`, `atlassian`, `salesforce`, `sap`,\r\n`github`, `linear`, `notion`, `vercel`, `excel`, `nord`, `dracula`,\r\n`catppuccin`, `ag-alpine`.\r\n\r\nEach preset defines every token the grid and the UI components read - including\r\nthe semantic status colors (`--sg-danger`, `--sg-success`, `--sg-warning`,\r\n`--sg-info`) and the focus ring, which follows the preset's accent. Presets\r\nflip with `data-theme=\"dark\"` automatically (see below).\r\n\r\nOverride individual tokens after the import to adjust a preset, or skip presets\r\nentirely and declare the tokens yourself - that is the rest of this page.\r\n\r\n## Declaring tokens yourself\r\n\r\nThe render component (`<SvGrid>`) ships its own scoped styles. You\r\nre-theme it by declaring `--sg-*` CSS custom properties at any level\r\nabove the grid - `:root` for the whole app, a wrapper `<div>` for one\r\ninstance, or directly on the `<SvGrid>` element itself.\r\n\r\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)\r\n\r\n## Token surface\r\n\r\nThe 20-odd tokens the renderer reads:\r\n\r\n| Token | What it paints |\r\n| -------------------------------- | ------------------------------------------- |\r\n| `--sg-bg` | Cell background |\r\n| `--sg-fg` | Cell text |\r\n| `--sg-muted` | Secondary text (footers, subtitles) |\r\n| `--sg-border` | Cell + header borders |\r\n| `--sg-header-bg` / `--sg-header-fg` | Header row |\r\n| `--sg-row-alt-bg` | Zebra rows |\r\n| `--sg-row-hover-bg` | Row + cell hover |\r\n| `--sg-row-height` | Row height |\r\n| `--sg-selection-bg` | Selected cell / row tint |\r\n| `--sg-accent` | Sort indicator, focus ring, primary buttons |\r\n| `--sg-focus-ring` | Keyboard focus outline |\r\n| `--sg-input-bg` / `--sg-input-border` | Inline editor + filter inputs |\r\n| `--sg-pill-active` / `-fg` | \"Active\" status pills |\r\n| `--sg-pill-pending` / `-fg` | \"Pending\" status pills |\r\n| `--sg-pill-inactive` / `-fg` | \"Inactive\" status pills |\r\n| `--sg-scrollbar-*` (10 tokens) | Custom-painted scrollbars |\r\n\r\n## Light + dark via `data-theme`\r\n\r\nThe gallery flips themes by writing `dark` or `light` to\r\n`html[data-theme]`. Every token redeclares under that selector:\r\n\r\n```css\r\n:root {\r\n --sg-bg: #ffffff;\r\n --sg-fg: #0f172a;\r\n --sg-border: #e2e8f0;\r\n --sg-header-bg: #f1f5f9;\r\n --sg-row-alt-bg: #f8fafc;\r\n --sg-row-hover-bg: #eef2ff;\r\n --sg-accent: #2563eb;\r\n}\r\n\r\nhtml[data-theme='dark'] {\r\n --sg-bg: #0f172a;\r\n --sg-fg: #f1f5f9;\r\n --sg-border: #334155;\r\n --sg-header-bg: #1e2433;\r\n --sg-row-alt-bg: #1b2230;\r\n --sg-row-hover-bg: #232b3c;\r\n --sg-accent: #3b82f6;\r\n color-scheme: dark;\r\n}\r\n```\r\n\r\nToggling is one line in the app shell:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let theme = $state<'light' | 'dark'>('dark')\r\n $effect(() => document.documentElement.setAttribute('data-theme', theme))\r\n</script>\r\n\r\n<button onclick={() => (theme = theme === 'dark' ? 'light' : 'dark')}>\r\n Toggle theme\r\n</button>\r\n```\r\n\r\n## Per-instance theming\r\n\r\nBecause the tokens are plain custom properties they cascade. To style a\r\nsingle grid, wrap it in a `<div>` that sets its own values:\r\n\r\n```svelte\r\n<div style=\"--sg-bg: #fff8f0; --sg-accent: #db2777;\">\r\n <SvGrid {data} {columns} features={features} />\r\n</div>\r\n```\r\n\r\nThe [`10-custom-cells-and-themes`](../../examples/src/demos/10-custom-cells-and-themes.svelte)\r\ndemo applies three full palettes (light / dark / high-contrast) this way.\r\n\r\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"520\"></div>\r\n\r\n## Density\r\n\r\nTwo ways:\r\n\r\n1. **Set `rowHeight` on `<SvGrid>`**. Numeric, in pixels. Drives the\r\n row height and the active-cell hit box.\r\n\r\n ```svelte\r\n <SvGrid {data} {columns} features={features} rowHeight={28} />\r\n ```\r\n\r\n2. **Override `--sg-row-height` on a wrapper.** Same effect, with the\r\n token shape if you'd rather express density in CSS.\r\n\r\n ```css\r\n .compact { --sg-row-height: 28px; }\r\n .comfortable { --sg-row-height: 48px; }\r\n ```\r\n\r\nA user-facing \"density selector\" is half a dozen lines:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\r\n const height = $derived(\r\n density === 'compact' ? 28 : density === 'comfortable' ? 48 : 36,\r\n )\r\n</script>\r\n\r\n<select bind:value={density}>\r\n <option value=\"compact\">Compact</option>\r\n <option value=\"normal\">Normal</option>\r\n <option value=\"comfortable\">Comfortable</option>\r\n</select>\r\n\r\n<SvGrid {data} {columns} features={features} rowHeight={height} />\r\n```\r\n\r\n## Sizing the grid\r\n\r\nThe wrapper renders inside whatever container you give it. The\r\n`containerHeight` prop sets the scrollable shell height:\r\n\r\n```svelte\r\n<!-- Numeric: px -->\r\n<SvGrid {data} {columns} features={features} containerHeight={520} />\r\n\r\n<!-- String: passed through to CSS -->\r\n<SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\r\n<SvGrid {data} {columns} features={features} containerHeight=\"auto\" />\r\n```\r\n\r\nFor a flex-grow layout the canonical recipe is:\r\n\r\n```svelte\r\n<div class=\"flex flex-col h-screen\">\r\n <header>…</header>\r\n <div class=\"flex-1 min-h-0\">\r\n <SvGrid {data} {columns} features={features} containerHeight=\"100%\" />\r\n </div>\r\n</div>\r\n```\r\n\r\nThe `min-h-0` is the bit that bites. Flex children default to\r\n`min-height: auto`, which prevents the inner scroll container from\r\nshrinking, which makes the whole page scroll instead of the grid.\r\n\r\n## Full Tailwind integration\r\n\r\nIf your app uses Tailwind, see [Tailwind integration](../help/tailwind.md)\r\nfor: install + PostCSS config, `@custom-variant` so the `dark:`\r\nmodifier follows `data-theme`, the override hooks for the stable\r\n`.sv-grid-*` class names, and the anti-patterns (don't `@apply` inside\r\ngrid selectors, don't put utility classes on grid children, don't\r\nfight column widths in CSS).\r\n"
2985
3006
  },
2986
3007
  {
2987
3008
  "slug": "getting-started/6-going-to-production",
@@ -2993,7 +3014,7 @@ export const docs = [
2993
3014
  "slug": "getting-started/starters",
2994
3015
  "path": "docs/getting-started/starters.md",
2995
3016
  "title": "Starters & scaffolding",
2996
- "markdown": "# Starters & scaffolding\n\nThe fastest way to a working SvGrid app. One command scaffolds a project\nwith the grid already wired up - no copy-paste, no config archaeology.\n\n![Running npm create @svgrid@latest scaffolds a SvelteKit project with SvGrid already wired into src/routes/+page.svelte and a template choice of minimal or admin-dashboard, then npm run dev starts the local server.](/docs-media/gs-starters.svg)\n\n## `npm create @svgrid`\n\n```bash\n# npm\nnpm create @svgrid@latest\n\n# pnpm\npnpm create @svgrid\n\n# yarn\nyarn create @svgrid\n```\n\nRun with no arguments and it walks you through a project name and a\ntemplate. Or pass them directly:\n\n```bash\n# Minimal Vite + Svelte 5 starter\nnpm create @svgrid@latest my-app -- --template minimal\n\n# Full SvelteKit admin dashboard\nnpm create @svgrid@latest my-admin -- --template admin-dashboard\n```\n\nThen:\n\n```bash\ncd my-app\nnpm install\nnpm run dev\n```\n\n### Templates\n\n| Template | Stack | Best for |\n| --- | --- | --- |\n| `minimal` | Vite + Svelte 5 + `@svgrid/grid`, one page | Dropping a grid into something quickly |\n| `admin-dashboard` | SvelteKit + Tailwind + `@svgrid/grid`, deploy to Vercel | A real dashboard / internal tool |\n\n### Options\n\n| Flag | Alias | Description |\n| --- | --- | --- |\n| `--template <name>` | `-t` | `minimal` or `admin-dashboard` |\n| `--force` | `-f` | Scaffold into a non-empty directory |\n| `--help` | `-h` | Show usage |\n\nBoth templates use the free MIT `@svgrid/grid` core. Add\n[`@svgrid/enterprise`](../enterprise/README.md) for export, import, print, pivot, and\nthe AI helpers.\n\n## The admin dashboard starter\n\nThe `admin-dashboard` template is a production-shaped SvelteKit app you\ncan fork directly from\n[the repo](https://github.com/sv-grid/sv-grid/tree/main/templates/sveltekit-admin-dashboard):\n\n- **App shell** - sidebar nav + top bar (`src/routes/+layout.svelte`)\n- **Overview** - KPI cards + a recent-orders grid (`src/routes/+page.svelte`)\n- **Orders** - full grid: sort, filter, row selection, inline editing, pagination\n- **Customers** - grid with column grouping (drag a column to the group bar)\n- **Prerendered to static HTML** for SEO and instant first paint; grids\n hydrate on the client (`prerender = true` in `src/routes/+layout.ts`)\n- **Sample data** in `src/lib/data.ts` - swap for your API or a\n SvelteKit `load` function\n\n### Deploy to Vercel\n\nThe starter ships with `@sveltejs/adapter-vercel` and a one-click deploy\nbutton in its README. From a forked or scaffolded copy:\n\n```bash\nnpm run build # static + serverless output\nnpm run preview # preview the production build\n```\n\nIf you're deploying the template straight from this monorepo's\nsubfolder, set the Vercel **Root Directory** to\n`templates/sveltekit-admin-dashboard`. Scaffolding a standalone copy\nfirst (above) avoids that step.\n\n## Adding the grid to an existing app\n\nAlready have a Svelte or SvelteKit project? Skip the scaffolder and\ninstall directly - see [Install](./1-install.md) and\n[First grid](./2-first-grid.md).\n\n## Frequently asked questions\n\n### What's the fastest way to start a SvGrid project?\n\nRun `npm create @svgrid@latest` (or `pnpm create @svgrid`). It scaffolds a\nVite + Svelte or SvelteKit project with the grid already wired up, then\n`npm install` and `npm run dev`.\n\n### Is there a SvelteKit admin dashboard template?\n\nYes. `npm create @svgrid@latest my-admin -- --template admin-dashboard`\nscaffolds a SvelteKit + Tailwind admin with multiple grids, prerendered\nfor SEO, and a one-click Deploy-to-Vercel button.\n\n### Do the starters require a Enterprise license?\n\nNo. Both templates use the free MIT `@svgrid/grid` core. Enterprise\nfeatures (export, import, print, pivot, AI) are an optional add-on that\nruns in evaluation without a key.\n\n## See also\n\n- [Install](./1-install.md) - add SvGrid to an existing project\n- [First grid](./2-first-grid.md) - the minimum runnable example\n- [Going to production](./6-going-to-production.md) - SSR, virtualization, a11y\n"
3017
+ "markdown": "# Starters & scaffolding\r\n\r\nThe fastest way to a working SvGrid app. One command scaffolds a project\r\nwith the grid already wired up - no copy-paste, no config archaeology.\r\n\r\n![Running npm create @svgrid@latest scaffolds a SvelteKit project with SvGrid already wired into src/routes/+page.svelte and a template choice of minimal or admin-dashboard, then npm run dev starts the local server.](/docs-media/gs-starters.svg)\r\n\r\n## `npm create @svgrid`\r\n\r\n```bash\r\n# npm\r\nnpm create @svgrid@latest\r\n\r\n# pnpm\r\npnpm create @svgrid\r\n\r\n# yarn\r\nyarn create @svgrid\r\n```\r\n\r\nRun with no arguments and it walks you through a project name and a\r\ntemplate. Or pass them directly:\r\n\r\n```bash\r\n# Minimal Vite + Svelte 5 starter\r\nnpm create @svgrid@latest my-app -- --template minimal\r\n\r\n# Full SvelteKit admin dashboard\r\nnpm create @svgrid@latest my-admin -- --template admin-dashboard\r\n```\r\n\r\nThen:\r\n\r\n```bash\r\ncd my-app\r\nnpm install\r\nnpm run dev\r\n```\r\n\r\n### Templates\r\n\r\n| Template | Stack | Best for |\r\n| --- | --- | --- |\r\n| `minimal` | Vite + Svelte 5 + `@svgrid/grid`, one page | Dropping a grid into something quickly |\r\n| `admin-dashboard` | SvelteKit + Tailwind + `@svgrid/grid`, deploy to Vercel | A real dashboard / internal tool |\r\n\r\n### Options\r\n\r\n| Flag | Alias | Description |\r\n| --- | --- | --- |\r\n| `--template <name>` | `-t` | `minimal` or `admin-dashboard` |\r\n| `--force` | `-f` | Scaffold into a non-empty directory |\r\n| `--help` | `-h` | Show usage |\r\n\r\nBoth templates use the free MIT `@svgrid/grid` core. Add\r\n[`@svgrid/enterprise`](../enterprise/README.md) for export, import, print, and pivot.\r\nThe AI helpers (natural-language filter, smart fill, summarize, classify) are part of\r\nthe free core - you register your own model provider.\r\n\r\n## The admin dashboard starter\r\n\r\nThe `admin-dashboard` template is a production-shaped SvelteKit app you\r\ncan fork directly from\r\n[the repo](https://github.com/sv-grid/sv-grid/tree/main/templates/sveltekit-admin-dashboard):\r\n\r\n- **App shell** - sidebar nav + top bar (`src/routes/+layout.svelte`)\r\n- **Overview** - KPI cards + a recent-orders grid (`src/routes/+page.svelte`)\r\n- **Orders** - full grid: sort, filter, row selection, inline editing, pagination\r\n- **Customers** - grid with column grouping (drag a column to the group bar)\r\n- **Prerendered to static HTML** for SEO and instant first paint; grids\r\n hydrate on the client (`prerender = true` in `src/routes/+layout.ts`)\r\n- **Sample data** in `src/lib/data.ts` - swap for your API or a\r\n SvelteKit `load` function\r\n\r\n### Deploy to Vercel\r\n\r\nThe starter ships with `@sveltejs/adapter-vercel` and a one-click deploy\r\nbutton in its README. From a forked or scaffolded copy:\r\n\r\n```bash\r\nnpm run build # static + serverless output\r\nnpm run preview # preview the production build\r\n```\r\n\r\nIf you're deploying the template straight from this monorepo's\r\nsubfolder, set the Vercel **Root Directory** to\r\n`templates/sveltekit-admin-dashboard`. Scaffolding a standalone copy\r\nfirst (above) avoids that step.\r\n\r\n## Adding the grid to an existing app\r\n\r\nAlready have a Svelte or SvelteKit project? Skip the scaffolder and\r\ninstall directly - see [Install](./1-install.md) and\r\n[First grid](./2-first-grid.md).\r\n\r\n## Frequently asked questions\r\n\r\n### What's the fastest way to start a SvGrid project?\r\n\r\nRun `npm create @svgrid@latest` (or `pnpm create @svgrid`). It scaffolds a\r\nVite + Svelte or SvelteKit project with the grid already wired up, then\r\n`npm install` and `npm run dev`.\r\n\r\n### Is there a SvelteKit admin dashboard template?\r\n\r\nYes. `npm create @svgrid@latest my-admin -- --template admin-dashboard`\r\nscaffolds a SvelteKit + Tailwind admin with multiple grids, prerendered\r\nfor SEO, and a one-click Deploy-to-Vercel button.\r\n\r\n### Do the starters require a Enterprise license?\r\n\r\nNo. Both templates use the free MIT `@svgrid/grid` core. Enterprise\r\nfeatures (export, import, print, pivot, AI) are an optional add-on that\r\nruns in evaluation without a key.\r\n\r\n## See also\r\n\r\n- [Install](./1-install.md) - add SvGrid to an existing project\r\n- [First grid](./2-first-grid.md) - the minimum runnable example\r\n- [Going to production](./6-going-to-production.md) - SSR, virtualization, a11y\r\n"
2997
3018
  },
2998
3019
  {
2999
3020
  "slug": "help/accessibility",
@@ -3047,7 +3068,7 @@ export const docs = [
3047
3068
  "slug": "help/benchmarks",
3048
3069
  "path": "docs/help/benchmarks.md",
3049
3070
  "title": "Performance benchmarks",
3050
- "markdown": "# Performance benchmarks\r\n\r\nHeadline numbers from the regression suite. Every figure below is from\r\nthe same hardware + browser configuration, re-measured on each release;\r\nthe script that produces them lives in\r\n`packages/grid/scripts/bench.ts` and is checked in.\r\n\r\nLive load - 100k rows x 100 columns with row + column virtualization:\r\n\r\n<div data-docs-demo=\"06-large-dataset\" data-height=\"500\"></div>\r\n\r\n## Test rig\r\n\r\n| Component | Spec |\r\n| --------- | ----------------------------------------- |\r\n| CPU | Apple M2 (8-core), 10W TDP |\r\n| RAM | 16 GB LPDDR5 |\r\n| Browser | Chrome 131 (release channel) |\r\n| Display | 1 page worth of cells visible at any time |\r\n| Throttle | None - the regression run isn't throttled, but we also publish a separate \"4x slow-down\" line below for each scenario |\r\n\r\nNumbers are the median of 5 runs after a warm-up pass. We track the\r\n**95th percentile of frame time** during scroll rather than mean FPS -\r\nthe former catches jank that mean averages smooths over.\r\n\r\n## Bundle size\r\n\r\nProduction build, gzipped. The first two rows come from\r\n`node packages/grid/scripts/measure-size.mjs` (Svelte excluded as a peer);\r\nsee the [bundle size reference](../reference/bundle-size.md).\r\n\r\n| Surface | gzip | Notes |\r\n| ----------------------------------- | ------ | -------------------------------------- |\r\n| `@svgrid/grid` (full `<SvGrid>`) | 80 kB | One import covers the entire renderer; + 9 kB CSS |\r\n| Headless engine (`createGrid`) | 2 kB | If you bring your own renderer |\r\n| Lazy chunks (charts, date editors, menus, export) | 64 kB | Loaded on demand, not in the initial bundle |\r\n| `@svgrid/enterprise` core | 8 kB | Export + print + import shells |\r\n| `@svgrid/enterprise` import module only | 6 kB | Imported via `'@svgrid/enterprise/import'` |\r\n| Peer: `jszip` | 35 kB | Loaded on first `xlsx` export *or* import |\r\n| Peer: `pdfmake` + vfs | ~280 kB| Loaded on first `pdf` export only |\r\n\r\nThe AI helpers are no longer in this table: they moved into the free\r\n`@svgrid/grid` and tree-shake out unless you import them.\r\n\r\nTree-shaking is friendly: importing `{ SvGrid, tableFeatures }`\r\nwithout `rowSortingFeature` doesn't pull the sort module.\r\n\r\n## First paint\r\n\r\n100k synthetic rows, 9 columns, default density, no virtualization\r\noverride. Measured from `mount()` to the first row painting:\r\n\r\n| Scenario | Time (ms) |\r\n| --------------------------------- | --------- |\r\n| 10 rows Ɨ 9 cols | 4 |\r\n| 1,000 rows Ɨ 9 cols | 14 |\r\n| 10,000 rows Ɨ 9 cols | 38 |\r\n| 100,000 rows Ɨ 9 cols (virtualized) | 82 |\r\n| 100,000 rows Ɨ 100 cols (row + col virtualization) | 110 |\r\n\r\nThe slope is sub-linear because virtualization caps the rendered cell\r\ncount regardless of dataset size.\r\n\r\n## Scroll performance\r\n\r\nSustained vertical scroll, 60 px/frame, measured as the 95th\r\npercentile frame time:\r\n\r\n| Scenario | p95 frame | Equivalent FPS |\r\n| ------------------------------------- | --------- | -------------- |\r\n| 100k rows Ɨ 9 cols | 8 ms | ~120 fps |\r\n| 100k rows Ɨ 100 cols (col virt) | 11 ms | ~90 fps |\r\n| 100k rows Ɨ 9 cols, custom cell snippets w/ sparklines | 14 ms | ~70 fps |\r\n| 100k rows Ɨ 9 cols, **4x CPU throttle** | 22 ms | ~45 fps |\r\n\r\nHorizontal scroll on a 100-column grid stays under 12 ms p95 because\r\nthe column virtualizer is identical machinery.\r\n\r\n## Sort, filter, group\r\n\r\nIn-memory operations on 100k rows:\r\n\r\n| Operation | Time (ms) |\r\n| ------------------------------------- | --------- |\r\n| Sort 100k rows by one column | 18 |\r\n| Sort 100k rows by 3 columns (multi-sort) | 28 |\r\n| Filter 100k rows (one operator) | 9 |\r\n| Filter 100k rows (5 operators ANDed) | 17 |\r\n| Group 100k rows by 2 columns + 3 aggregators | 36 |\r\n| Pivot 100k facts → 4 row dims Ɨ 2 col dims Ɨ 3 measures (see demo 52) | 62 |\r\n\r\nThe sort path uses a stable comparator built per-column to keep\r\nallocations down; the filter pipeline short-circuits on the first\r\nfailing predicate.\r\n\r\n## Memory\r\n\r\nHeap snapshot at idle, 100k rows Ɨ 9 columns loaded, after a full\r\nscroll pass:\r\n\r\n- ~22 MB heap (the Row objects + the visible-cell pool).\r\n- Virtualization keeps the rendered DOM under ~600 `<td>` nodes\r\n regardless of dataset size.\r\n- No retained references when the grid unmounts - the cleanup path is\r\n exercised by the unmount test in `svgrid.behavior.test.ts`.\r\n\r\n## Server-side / chunked loading\r\n\r\nDemo [33. Server-side infinite scroll](https://svgrid.com/demos/33-server-infinite/) covers the chunked-load path. Numbers from that demo:\r\n\r\n| Scenario | Result |\r\n| ------------------------------------------------ | --------------------------------------- |\r\n| Initial paint, sparse 100k-row dataset | 110 ms to first chunk visible |\r\n| Scroll 50,000 rows in 1.5 s (fast wheel-flick) | 16 chunk requests cancelled mid-flight |\r\n| Sort 100k server-side rows | round-trip dominated by the mock latency (50-140 ms) |\r\n\r\n## AI helpers\r\n\r\nEnd-to-end timings against the bundled `mockAIProvider`:\r\n\r\n| Helper | Median time (ms) |\r\n| --------------- | ---------------- |\r\n| `aiFilter` | 350-750 (mock latency dominated) |\r\n| `aiSmartFill` (50 rows) | 400-900 |\r\n| `aiSummarize` | 350-750 |\r\n| `aiClassify` (20 rows) | 400-750 |\r\n\r\nAgainst a real model the latency is provider-side. The grid's own\r\nprompt-build + result-parse work stays under ~6 ms even for 1000-row\r\nclassify jobs.\r\n\r\n## Import / export\r\n\r\n| Operation | Time |\r\n| ------------------------------------ | ------ |\r\n| Parse CSV, 10k rows Ɨ 9 cols | 28 ms |\r\n| Parse xlsx, 10k rows Ɨ 9 cols | 140 ms (jszip unzip-dominated) |\r\n| Export CSV, 10k rows Ɨ 9 cols | 18 ms |\r\n| Export xlsx, 10k rows Ɨ 9 cols | 220 ms |\r\n| Export PDF, 1k rows Ɨ 9 cols (pdfmake) | 700 ms |\r\n\r\n## Reproducing locally\r\n\r\n```bash\r\ngit clone https://github.com/sv-grid/sv-grid\r\ncd sv-grid\r\npnpm install\r\npnpm bench # runs the suite, prints the same table\r\npnpm bench --json > my-results.json # for trend tracking\r\n```\r\n\r\nThe bench script also produces a comparison table against the previous\r\nrun if you pass `--baseline=path/to/prev.json`. Regressions over 10%\r\nfail CI on the main branch.\r\n\r\n## What we *don't* claim\r\n\r\n- \"Smoothest grid on the market\" - that depends entirely on what your\r\n cells render. A sparkline + currency formatter in every cell costs\r\n more than a number, and we don't pretend otherwise.\r\n- \"Zero allocations during scroll\" - the virtualizer recycles DOM\r\n nodes but cell snippets still allocate. The numbers above include\r\n real-world snippets (status pills, mini-bars).\r\n- Single-thread performance > 1M rows. For >1M, do the heavy lifting\r\n on the server and feed chunks through the [server-side infinite\r\n scroll pattern](https://svgrid.com/demos/33-server-infinite/).\r\n\r\n## See also\r\n\r\n- [Browser support](./browser-support.md) - the matrix the benchmarks\r\n ran against.\r\n- [Testing and quality](./testing-and-quality.md) - the coverage\r\n thresholds that gate every release.\r\n\r\n## Frequently asked questions\r\n\r\n### How fast is SvGrid?\r\n\r\nIt virtualizes both rows and columns, so only the visible window is in the DOM -\r\na 100,000-row Ɨ 100-column grid scrolls smoothly. The numbers on this page come\r\nfrom a checked-in regression suite re-measured on every release, not marketing\r\nestimates.\r\n\r\n### How many rows can SvGrid handle?\r\n\r\nClient-side, 100k+ rows scroll smoothly thanks to virtualization. For millions\r\nof rows, page or chunk from the server (see Server-side data). The DOM only ever\r\nholds the visible window regardless of total row count.\r\n\r\n### How fast is SvGrid, and how big is it?\r\n\r\nIt ships a much smaller bundle (~80 KB gzipped for the full render component,\r\nor ~2 KB for the headless core) and virtualizes by default. Raw scroll\r\nperformance is comparable for typical workloads; the bigger practical win is\r\nbundle size and a Svelte-native runtime with no framework bridge.\r\n"
3071
+ "markdown": "# Performance benchmarks\r\n\r\nHeadline numbers from the regression suite. Every figure below is from\r\nthe same hardware + browser configuration, re-measured on each release.\r\n\r\nReproduce them yourself rather than taking these on trust: the\r\n[benchmark harness](../recipes/benchmark-harness.md) is a copy-paste\r\n`<SvGrid>` probe that measures time-to-first-paint across any (rows x\r\ncolumns) matrix, and the bundle-size figures come from `pnpm size`\r\n(`packages/grid/scripts/measure-size.mjs`). Numbers from your own\r\nmachine and data shape are the ones worth planning against.\r\n\r\nLive load - 100k rows x 100 columns with row + column virtualization:\r\n\r\n<div data-docs-demo=\"06-large-dataset\" data-height=\"500\"></div>\r\n\r\n## Test rig\r\n\r\n| Component | Spec |\r\n| --------- | ----------------------------------------- |\r\n| CPU | Apple M2 (8-core), 10W TDP |\r\n| RAM | 16 GB LPDDR5 |\r\n| Browser | Chrome 131 (release channel) |\r\n| Display | 1 page worth of cells visible at any time |\r\n| Throttle | None - the regression run isn't throttled, but we also publish a separate \"4x slow-down\" line below for each scenario |\r\n\r\nNumbers are the median of 5 runs after a warm-up pass. We track the\r\n**95th percentile of frame time** during scroll rather than mean FPS -\r\nthe former catches jank that mean averages smooths over.\r\n\r\n## Bundle size\r\n\r\nProduction build, gzipped. The first two rows come from\r\n`node packages/grid/scripts/measure-size.mjs` (Svelte excluded as a peer);\r\nsee the [bundle size reference](../reference/bundle-size.md).\r\n\r\n| Surface | gzip | Notes |\r\n| ----------------------------------- | ------ | -------------------------------------- |\r\n| `@svgrid/grid` (full `<SvGrid>`) | 80 kB | One import covers the entire renderer; + 9 kB CSS |\r\n| Headless engine (`createGrid`) | 2 kB | If you bring your own renderer |\r\n| Lazy chunks (charts, date editors, menus, export) | 64 kB | Loaded on demand, not in the initial bundle |\r\n| `@svgrid/enterprise` core | 8 kB | Export + print + import shells |\r\n| `@svgrid/enterprise` import module only | 6 kB | Imported via `'@svgrid/enterprise/import'` |\r\n| Peer: `jszip` | 35 kB | Loaded on first `xlsx` export *or* import |\r\n| Peer: `pdfmake` + vfs | ~280 kB| Loaded on first `pdf` export only |\r\n\r\nThe AI helpers are no longer in this table: they moved into the free\r\n`@svgrid/grid` and tree-shake out unless you import them.\r\n\r\nTree-shaking is friendly: importing `{ SvGrid, tableFeatures }`\r\nwithout `rowSortingFeature` doesn't pull the sort module.\r\n\r\n## First paint\r\n\r\n100k synthetic rows, 9 columns, default density, no virtualization\r\noverride. Measured from `mount()` to the first row painting:\r\n\r\n| Scenario | Time (ms) |\r\n| --------------------------------- | --------- |\r\n| 10 rows Ɨ 9 cols | 4 |\r\n| 1,000 rows Ɨ 9 cols | 14 |\r\n| 10,000 rows Ɨ 9 cols | 38 |\r\n| 100,000 rows Ɨ 9 cols (virtualized) | 82 |\r\n| 100,000 rows Ɨ 100 cols (row + col virtualization) | 110 |\r\n\r\nThe slope is sub-linear because virtualization caps the rendered cell\r\ncount regardless of dataset size.\r\n\r\n## Scroll performance\r\n\r\nSustained vertical scroll, 60 px/frame, measured as the 95th\r\npercentile frame time:\r\n\r\n| Scenario | p95 frame | Equivalent FPS |\r\n| ------------------------------------- | --------- | -------------- |\r\n| 100k rows Ɨ 9 cols | 8 ms | ~120 fps |\r\n| 100k rows Ɨ 100 cols (col virt) | 11 ms | ~90 fps |\r\n| 100k rows Ɨ 9 cols, custom cell snippets w/ sparklines | 14 ms | ~70 fps |\r\n| 100k rows Ɨ 9 cols, **4x CPU throttle** | 22 ms | ~45 fps |\r\n\r\nHorizontal scroll on a 100-column grid stays under 12 ms p95 because\r\nthe column virtualizer is identical machinery.\r\n\r\n## Sort, filter, group\r\n\r\nIn-memory operations on 100k rows:\r\n\r\n| Operation | Time (ms) |\r\n| ------------------------------------- | --------- |\r\n| Sort 100k rows by one column | 18 |\r\n| Sort 100k rows by 3 columns (multi-sort) | 28 |\r\n| Filter 100k rows (one operator) | 9 |\r\n| Filter 100k rows (5 operators ANDed) | 17 |\r\n| Group 100k rows by 2 columns + 3 aggregators | 36 |\r\n| Pivot 100k facts → 4 row dims Ɨ 2 col dims Ɨ 3 measures (see demo 52) | 62 |\r\n\r\nThe sort path uses a stable comparator built per-column to keep\r\nallocations down; the filter pipeline short-circuits on the first\r\nfailing predicate.\r\n\r\n## Memory\r\n\r\nHeap snapshot at idle, 100k rows Ɨ 9 columns loaded, after a full\r\nscroll pass:\r\n\r\n- ~22 MB heap (the Row objects + the visible-cell pool).\r\n- Virtualization keeps the rendered DOM under ~600 `<td>` nodes\r\n regardless of dataset size.\r\n- No retained references when the grid unmounts - the cleanup path is\r\n exercised by the unmount test in `svgrid.behavior.test.ts`.\r\n\r\n## Server-side / chunked loading\r\n\r\nDemo [33. Server-side infinite scroll](https://svgrid.com/demos/33-server-infinite/) covers the chunked-load path. Numbers from that demo:\r\n\r\n| Scenario | Result |\r\n| ------------------------------------------------ | --------------------------------------- |\r\n| Initial paint, sparse 100k-row dataset | 110 ms to first chunk visible |\r\n| Scroll 50,000 rows in 1.5 s (fast wheel-flick) | 16 chunk requests cancelled mid-flight |\r\n| Sort 100k server-side rows | round-trip dominated by the mock latency (50-140 ms) |\r\n\r\n## AI helpers\r\n\r\nEnd-to-end timings against the bundled `mockAIProvider`:\r\n\r\n| Helper | Median time (ms) |\r\n| --------------- | ---------------- |\r\n| `aiFilter` | 350-750 (mock latency dominated) |\r\n| `aiSmartFill` (50 rows) | 400-900 |\r\n| `aiSummarize` | 350-750 |\r\n| `aiClassify` (20 rows) | 400-750 |\r\n\r\nAgainst a real model the latency is provider-side. The grid's own\r\nprompt-build + result-parse work stays under ~6 ms even for 1000-row\r\nclassify jobs.\r\n\r\n## Import / export\r\n\r\n| Operation | Time |\r\n| ------------------------------------ | ------ |\r\n| Parse CSV, 10k rows Ɨ 9 cols | 28 ms |\r\n| Parse xlsx, 10k rows Ɨ 9 cols | 140 ms (jszip unzip-dominated) |\r\n| Export CSV, 10k rows Ɨ 9 cols | 18 ms |\r\n| Export xlsx, 10k rows Ɨ 9 cols | 220 ms |\r\n| Export PDF, 1k rows Ɨ 9 cols (pdfmake) | 700 ms |\r\n\r\n## Reproducing locally\r\n\r\n```bash\r\ngit clone https://github.com/sv-grid/sv-grid\r\ncd sv-grid\r\npnpm install\r\npnpm bench # runs the suite, prints the same table\r\npnpm bench --json > my-results.json # for trend tracking\r\n```\r\n\r\nThe bench script also produces a comparison table against the previous\r\nrun if you pass `--baseline=path/to/prev.json`. Regressions over 10%\r\nfail CI on the main branch.\r\n\r\n## What we *don't* claim\r\n\r\n- \"Smoothest grid on the market\" - that depends entirely on what your\r\n cells render. A sparkline + currency formatter in every cell costs\r\n more than a number, and we don't pretend otherwise.\r\n- \"Zero allocations during scroll\" - the virtualizer recycles DOM\r\n nodes but cell snippets still allocate. The numbers above include\r\n real-world snippets (status pills, mini-bars).\r\n- Single-thread performance > 1M rows. For >1M, do the heavy lifting\r\n on the server and feed chunks through the [server-side infinite\r\n scroll pattern](https://svgrid.com/demos/33-server-infinite/).\r\n\r\n## See also\r\n\r\n- [Browser support](./browser-support.md) - the matrix the benchmarks\r\n ran against.\r\n- [Testing and quality](./testing-and-quality.md) - the coverage\r\n thresholds that gate every release.\r\n\r\n## Frequently asked questions\r\n\r\n### How fast is SvGrid?\r\n\r\nIt virtualizes both rows and columns, so only the visible window is in the DOM -\r\na 100,000-row Ɨ 100-column grid scrolls smoothly. The numbers on this page come\r\nfrom a checked-in regression suite re-measured on every release, not marketing\r\nestimates.\r\n\r\n### How many rows can SvGrid handle?\r\n\r\nClient-side, 100k+ rows scroll smoothly thanks to virtualization. For millions\r\nof rows, page or chunk from the server (see Server-side data). The DOM only ever\r\nholds the visible window regardless of total row count.\r\n\r\n### How fast is SvGrid, and how big is it?\r\n\r\nIt ships a much smaller bundle (~80 KB gzipped for the full render component,\r\nor ~2 KB for the headless core) and virtualizes by default. Raw scroll\r\nperformance is comparable for typical workloads; the bigger practical win is\r\nbundle size and a Svelte-native runtime with no framework bridge.\r\n"
3051
3072
  },
3052
3073
  {
3053
3074
  "slug": "help/browser-support",
@@ -3077,7 +3098,7 @@ export const docs = [
3077
3098
  "slug": "help/cells/conditional-formatting",
3078
3099
  "path": "docs/help/cells/conditional-formatting.md",
3079
3100
  "title": "Conditional formatting",
3080
- "markdown": "# Conditional formatting\n\nConditional formatting colors a cell by its value. SvGrid ships it as a\ndeclarative engine prop, `conditionalFormats`, so you describe the rules once\nand the grid paints every cell - no per-cell `cell` snippet required.\n\n<div data-docs-demo=\"141-conditional-formatting\" data-height=\"480\"></div>\n\nIt goes beyond the `cellClass(ctx)` callback (which only toggles static CSS\nclasses): color scales and data bars need a value computed against the\ncolumn's min/max range, which the engine does for you.\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, type ColumnDef, type ConditionalFormat } from '@svgrid/grid'\n\n type Row = { rep: string; revenue: number; score: number }\n\n const conditionalFormats: ConditionalFormat<Row>[] = [\n { type: 'dataBar', columns: ['revenue'], color: '#3b82f6' },\n { type: 'colorScale', columns: ['score'], min: '#fca5a5', mid: '#fde68a', max: '#86efac' },\n ]\n</script>\n\n<SvGrid {data} {columns} {conditionalFormats} />\n```\n\n## Format kinds\n\n### `colorScale` - gradient fill\n\nA 2-stop (`min`/`max`) or 3-stop (`min`/`mid`/`max`) gradient mapped across the\ncolumn's value range. Fix the scale with `minValue`/`maxValue` to make rows\ncomparable.\n\n```ts\n{ type: 'colorScale', columns: ['score'], min: '#fca5a5', mid: '#fde68a', max: '#86efac', minValue: 0, maxValue: 100 }\n```\n\nThe color scale has several modes for turning a column into a live heat map:\n\n**Alpha ramp (`mode: 'alpha'`)** - keep a single `base` color and interpolate its\n*opacity* instead of its hue. Because the tint is translucent it composites over\nzebra striping, selection, and pinned backgrounds rather than painting over\nthem - the cleanest heat-map look. Tune the opacity range with\n`alphaBounds: [min, max]` (default `[0.05, 0.85]`).\n\n```ts\n{ type: 'colorScale', columns: ['score'], mode: 'alpha', base: '#2563eb', alphaBounds: [0.08, 0.8] }\n```\n\n**Zero-centred (`zeroCentred: true`)** - a diverging scale pinned at 0: negatives\nand positives shade outward from a neutral midpoint, symmetric around zero.\nIdeal for P&L, price deltas, or day-over-day change.\n\n```ts\n{ type: 'colorScale', columns: ['pnl'], min: '#ef4444', mid: '#f8fafc', max: '#22c55e', zeroCentred: true }\n```\n\n**Percent bounds (`bounds: 'percent'`)** - read `minValue`/`maxValue` as 0..100\npositions along the column's own span, so you can tint \"the top 20%\" without\nknowing the numbers up front.\n\n```ts\n{ type: 'colorScale', columns: ['score'], min: '#f8fafc', max: '#2563eb', bounds: 'percent', minValue: 80, maxValue: 100 }\n```\n\n**Banded / N-stop (`stops`)** - supply any number of `{ offset, color }` stops\n(offsets 0..1) for a traffic-light or multi-band ramp; this overrides\n`min`/`mid`/`max`.\n\n```ts\n{ type: 'colorScale', columns: ['risk'], stops: [\n { offset: 0, color: '#22c55e' }, { offset: 0.5, color: '#f59e0b' }, { offset: 1, color: '#ef4444' },\n] }\n```\n\n**Column comparison (`compareColumn`)** - tint each cell by its value as a\nproportion of another field on the *same row* (filled vs target, open vs total)\ninstead of the column extremes. No column stats needed.\n\n```ts\n{ type: 'colorScale', columns: ['filled'], mode: 'alpha', base: '#2563eb', compareColumn: 'target' }\n```\n\nAdd `reverse: true` to flip any ramp so the lowest values draw the eye, and\n`tooltip: 'value' | 'percent'` to show the raw value or its position on the ramp\non hover.\n\n### `dataBar` - in-cell bar\n\nAn in-cell horizontal bar proportional to the value. Diverging data (can go\nnegative) gets `negativeColor`. `showValue: false` hides the text and shows the\nbar alone. `gradient: true` fills the bar with a left-to-right gradient.\n\n```ts\n{ type: 'dataBar', columns: ['revenue'], color: '#3b82f6', negativeColor: '#ef4444' }\n```\n\nData bars accept the same relational range options as the color scale:\n`bounds: 'percent'` and `compareColumn: 'target'` size the bar against a percent\nof the span or another column on the row.\n\n```ts\n{ type: 'dataBar', columns: ['filled'], color: '#3b82f6', compareColumn: 'target' }\n```\n\n### `iconSet` - threshold icons\n\nAn icon chosen by ascending `thresholds` (n thresholds => n+1 buckets). Built-in\nsets: `'arrows'`, `'traffic'`, `'triangles'`. `iconOnly: true` hides the number.\n\n```ts\n// growth < 0 -> down, 0..10 -> flat, >= 10 -> up\n{ type: 'iconSet', columns: ['growth'], set: 'arrows', thresholds: [0, 10] }\n```\n\n### `rule` - style on a predicate\n\nApply `background` / `color` / `fontWeight` when `when(ctx)` returns true. The\npredicate receives the typed row, so you can key off other fields.\n\n```ts\n{ type: 'rule', columns: ['churn'], when: ({ value }) => Number(value) >= 20,\n background: '#fee2e2', color: '#991b1b', fontWeight: 700 }\n```\n\n## Scoping and precedence\n\n- `columns: [...]` limits a format to those column ids. Omit it to apply to\n every column.\n- Formats are evaluated in array order; **later entries win** on conflict, so\n list general formats first and specific overrides last.\n- Empty / non-numeric cells are skipped by the numeric formats (color scale,\n data bar, icon set) and never count toward a column's min/max.\n\n## Stat scope\n\n`colorScale` and `dataBar` scale against a column's min/max. By default that\nrange is computed over the **visible** rows (after filtering and paging), so the\nheat map adapts to what's on screen. Set `conditionalStatScope=\"all\"` on the\ngrid to scale against the full unfiltered dataset instead, keeping the ramp put\nas you filter.\n\n```svelte\n<SvGrid {data} {columns} {conditionalFormats} conditionalStatScope=\"all\" />\n```\n\n## Notes\n\n- The color-scale fill and data bar render as layers behind the text, so they\n survive app stylesheets that force the cell background.\n- `mode: 'alpha'` tints are translucent, so they layer cleanly over zebra rows,\n selection, and pinned columns; `mode: 'hue'` (default) paints an opaque fill\n and auto-picks a legible text color.\n- The resolver is exported as `resolveCellFormat(value, row, columnId, formats,\n stat)` if you want to compute the same result yourself.\n\nSee the live [Conditional formatting](https://sv-grid.com/demos/141-conditional-formatting)\ndemo.\n"
3101
+ "markdown": "# Conditional formatting\n\nConditional formatting colors a cell by its value. SvGrid ships it as a\ndeclarative engine prop, `conditionalFormats`, so you describe the rules once\nand the grid paints every cell - no per-cell `cell` snippet required.\n\n<div data-docs-demo=\"141-conditional-formatting\" data-height=\"480\"></div>\n\nIt goes beyond the `cellClass(ctx)` callback (which only toggles static CSS\nclasses): color scales and data bars need a value computed against the\ncolumn's min/max range, which the engine does for you.\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, type ColumnDef, type ConditionalFormat } from '@svgrid/grid'\n\n type Row = { rep: string; revenue: number; score: number }\n\n const conditionalFormats: ConditionalFormat<Row>[] = [\n { type: 'dataBar', columns: ['revenue'], color: '#3b82f6' },\n { type: 'colorScale', columns: ['score'], min: '#fca5a5', mid: '#fde68a', max: '#86efac' },\n ]\n</script>\n\n<SvGrid {data} {columns} {conditionalFormats} />\n```\n\n## Format kinds\n\n### `colorScale` - gradient fill\n\nA 2-stop (`min`/`max`) or 3-stop (`min`/`mid`/`max`) gradient mapped across the\ncolumn's value range. Fix the scale with `minValue`/`maxValue` to make rows\ncomparable.\n\n```ts\n{ type: 'colorScale', columns: ['score'], min: '#fca5a5', mid: '#fde68a', max: '#86efac', minValue: 0, maxValue: 100 }\n```\n\nThe color scale has several modes for turning a column into a live heat map:\n\n**Alpha ramp (`mode: 'alpha'`)** - keep a single `base` color and interpolate its\n*opacity* instead of its hue. Because the tint is translucent it composites over\nzebra striping, selection, and pinned backgrounds rather than painting over\nthem - the cleanest heat-map look. Tune the opacity range with\n`alphaBounds: [min, max]` (default `[0.05, 0.85]`).\n\n```ts\n{ type: 'colorScale', columns: ['score'], mode: 'alpha', base: '#2563eb', alphaBounds: [0.08, 0.8] }\n```\n\n**Zero-centred (`zeroCentred: true`)** - a diverging scale pinned at 0: negatives\nand positives shade outward from a neutral midpoint, symmetric around zero.\nIdeal for P&L, price deltas, or day-over-day change.\n\n```ts\n{ type: 'colorScale', columns: ['pnl'], min: '#ef4444', mid: '#f8fafc', max: '#22c55e', zeroCentred: true }\n```\n\n**Percent bounds (`bounds: 'percent'`)** - read `minValue`/`maxValue` as 0..100\npositions along the column's own span, so you can tint \"the top 20%\" without\nknowing the numbers up front.\n\n```ts\n{ type: 'colorScale', columns: ['score'], min: '#f8fafc', max: '#2563eb', bounds: 'percent', minValue: 80, maxValue: 100 }\n```\n\n**Banded / N-stop (`stops`)** - supply any number of `{ offset, color }` stops\n(offsets 0..1) for a traffic-light or multi-band ramp; this overrides\n`min`/`mid`/`max`.\n\n```ts\n{ type: 'colorScale', columns: ['risk'], stops: [\n { offset: 0, color: '#22c55e' }, { offset: 0.5, color: '#f59e0b' }, { offset: 1, color: '#ef4444' },\n] }\n```\n\n**Column comparison (`compareColumn`)** - tint each cell by its value as a\nproportion of another field on the *same row* (filled vs target, open vs total)\ninstead of the column extremes. No column stats needed.\n\n```ts\n{ type: 'colorScale', columns: ['filled'], mode: 'alpha', base: '#2563eb', compareColumn: 'target' }\n```\n\nAdd `reverse: true` to flip any ramp so the lowest values draw the eye, and\n`tooltip: 'value' | 'percent'` to show the raw value or its position on the ramp\non hover.\n\n### `dataBar` - in-cell bar\n\nAn in-cell horizontal bar proportional to the value. Diverging data (can go\nnegative) gets `negativeColor`. `showValue: false` hides the text and shows the\nbar alone. `gradient: true` fills the bar with a left-to-right gradient.\n\n```ts\n{ type: 'dataBar', columns: ['revenue'], color: '#3b82f6', negativeColor: '#ef4444' }\n```\n\nData bars accept the same relational range options as the color scale:\n`bounds: 'percent'` and `compareColumn: 'target'` size the bar against a percent\nof the span or another column on the row.\n\n```ts\n{ type: 'dataBar', columns: ['filled'], color: '#3b82f6', compareColumn: 'target' }\n```\n\n### `iconSet` - threshold icons\n\nAn icon chosen by ascending `thresholds` (n thresholds => n+1 buckets). Built-in\nsets: `'arrows'`, `'traffic'`, `'triangles'`. `iconOnly: true` hides the number.\n\n```ts\n// growth < 0 -> down, 0..10 -> flat, >= 10 -> up\n{ type: 'iconSet', columns: ['growth'], set: 'arrows', thresholds: [0, 10] }\n```\n\n### `rule` - style on a predicate\n\nApply `background` / `color` / `fontWeight` when `when(ctx)` returns true. The\npredicate receives the typed row, so you can key off other fields.\n\n```ts\n{ type: 'rule', columns: ['churn'], when: ({ value }) => Number(value) >= 20,\n background: '#fee2e2', color: '#991b1b', fontWeight: 700 }\n```\n\n## Scoping and precedence\n\n- `columns: [...]` limits a format to those column ids. Omit it to apply to\n every column.\n- Formats are evaluated in array order; **later entries win** on conflict, so\n list general formats first and specific overrides last.\n- Empty / non-numeric cells are skipped by the numeric formats (color scale,\n data bar, icon set) and never count toward a column's min/max.\n\n## Stat scope\n\n`colorScale` and `dataBar` scale against a column's min/max. `conditionalStatScope`\npicks which rows feed that range:\n\n| Value | Rows scanned |\n| --- | --- |\n| `filtered` (default) | Everything that survives the filters, ignoring the page slice. |\n| `visible` | Only the rows on screen, so each page normalizes to itself. |\n| `all` | The full unfiltered dataset, so the ramp stays put as you filter. |\n\nThe default deliberately ignores paging: with a per-page range the same value\nrenders one colour on page 1 and a different one on page 2, which makes the\nencoding misleading. Opt into `visible` when you actually want per-page\nnormalization.\n\n```svelte\n<SvGrid {data} {columns} {conditionalFormats} conditionalStatScope=\"all\" />\n```\n\n## Notes\n\n- The color-scale fill and data bar render as layers behind the text, so they\n survive app stylesheets that force the cell background.\n- `mode: 'alpha'` tints are translucent, so they layer cleanly over zebra rows,\n selection, and pinned columns; `mode: 'hue'` (default) paints an opaque fill\n and auto-picks a legible text color.\n- The resolver is exported as `resolveCellFormat(value, row, columnId, formats,\n stat)` if you want to compute the same result yourself.\n\nSee the live [Conditional formatting](https://svgrid.com/demos/141-conditional-formatting/)\ndemo.\n"
3081
3102
  },
3082
3103
  {
3083
3104
  "slug": "help/cells/expressions",
@@ -3101,7 +3122,7 @@ export const docs = [
3101
3122
  "slug": "help/cells/sparklines",
3102
3123
  "path": "docs/help/cells/sparklines.md",
3103
3124
  "title": "Sparklines",
3104
- "markdown": "# Sparklines\n\nA sparkline is a tiny, word-sized chart drawn inside a single cell. SvGrid\nrenders them as a first-class column type: set `sparkline` on a column whose\nvalue is an array of numbers and the grid paints an inline SVG. No chart\nlibrary, no custom cell snippet.\n\n<div data-docs-demo=\"140-sparkline-cells\" data-height=\"480\"></div>\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, type ColumnDef } from '@svgrid/grid'\n\n type Row = { product: string; revenue: number[] }\n\n const columns: ColumnDef<{}, Row>[] = [\n { field: 'product', header: 'Product' },\n { field: 'revenue', header: 'Trend', sparkline: { type: 'line' } },\n ]\n</script>\n\n<SvGrid {data} {columns} />\n```\n\n## Value shape\n\nThe cell value is an array of numbers. A comma- or space-separated string\nworks too (`\"1, 2, 3\"`), so server payloads that send a CSV string render\nwithout massaging. Non-finite entries are dropped; an empty array renders\nnothing.\n\n## Chart types\n\n| `type` | Looks like |\n| ----------- | -------------------------------------------- |\n| `'line'` | A single polyline with an end-point dot (default) |\n| `'area'` | A line plus a translucent fill to the baseline |\n| `'bar'` | One column per value, scaled to the row's min..max |\n| `'winloss'` | Fixed-height up/down bars - sign only (W/L streaks) |\n\n## Options (`SparklineConfig`)\n\n| Option | Default | Notes |\n| --------------- | ------------------------ | -------------------------------------------------- |\n| `type` | `'line'` | One of the four above. |\n| `color` | `var(--sg-accent)` | Stroke (line/area) or positive fill (bar/winloss). |\n| `negativeColor` | `#ef4444` | Fill for negative bars / losses. |\n| `width` | `88` | SVG width in px. |\n| `height` | `22` | SVG height in px. |\n| `min` / `max` | derived from the row | Fix the value scale so rows are comparable. |\n| `lineWidth` | `1.5` | Stroke width (line/area). |\n| `lastPoint` | `true` | Draw the end-cap dot on line/area. |\n\n```ts\n// Green/red diverging bars on a column that can go negative:\n{ field: 'delta', sparkline: { type: 'bar', color: '#16a34a', negativeColor: '#ef4444' } }\n\n// Comparable rows: pin every sparkline to the same 0..100 scale:\n{ field: 'score', sparkline: { type: 'area', min: 0, max: 100 } }\n```\n\n## Notes\n\n- A custom `cell` renderer wins if both `cell` and `sparkline` are set.\n- Sparklines are decorative SVG with an `aria-label` summarising the series\n (point count + last value). For a screen-reader-friendly exact readout,\n pair the chart column with a plain numeric column.\n- The geometry helper is exported as `buildSparkline(values, config)` if you\n want to render the same chart outside a grid cell.\n\nSee the live [Sparkline cells](https://sv-grid.com/demos/140-sparkline-cells)\ndemo.\n"
3125
+ "markdown": "# Sparklines\n\nA sparkline is a tiny, word-sized chart drawn inside a single cell. SvGrid\nrenders them as a first-class column type: set `sparkline` on a column whose\nvalue is an array of numbers and the grid paints an inline SVG. No chart\nlibrary, no custom cell snippet.\n\n<div data-docs-demo=\"140-sparkline-cells\" data-height=\"480\"></div>\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, type ColumnDef } from '@svgrid/grid'\n\n type Row = { product: string; revenue: number[] }\n\n const columns: ColumnDef<{}, Row>[] = [\n { field: 'product', header: 'Product' },\n { field: 'revenue', header: 'Trend', sparkline: { type: 'line' } },\n ]\n</script>\n\n<SvGrid {data} {columns} />\n```\n\n## Value shape\n\nThe cell value is an array of numbers. A comma- or space-separated string\nworks too (`\"1, 2, 3\"`), so server payloads that send a CSV string render\nwithout massaging. Non-finite entries are dropped; an empty array renders\nnothing.\n\n## Chart types\n\n| `type` | Looks like |\n| ----------- | -------------------------------------------- |\n| `'line'` | A single polyline with an end-point dot (default) |\n| `'area'` | A line plus a translucent fill to the baseline |\n| `'bar'` | One column per value, scaled to the row's min..max |\n| `'winloss'` | Fixed-height up/down bars - sign only (W/L streaks) |\n\n## Options (`SparklineConfig`)\n\n| Option | Default | Notes |\n| --------------- | ------------------------ | -------------------------------------------------- |\n| `type` | `'line'` | One of the four above. |\n| `color` | `var(--sg-accent)` | Stroke (line/area) or positive fill (bar/winloss). |\n| `negativeColor` | `#ef4444` | Fill for negative bars / losses. |\n| `width` | `88` | SVG width in px. |\n| `height` | `22` | SVG height in px. |\n| `min` / `max` | derived from the row | Fix the value scale so rows are comparable. |\n| `lineWidth` | `1.5` | Stroke width (line/area). |\n| `lastPoint` | `true` | Draw the end-cap dot on line/area. |\n\n```ts\n// Green/red diverging bars on a column that can go negative:\n{ field: 'delta', sparkline: { type: 'bar', color: '#16a34a', negativeColor: '#ef4444' } }\n\n// Comparable rows: pin every sparkline to the same 0..100 scale:\n{ field: 'score', sparkline: { type: 'area', min: 0, max: 100 } }\n```\n\n## Notes\n\n- A custom `cell` renderer wins if both `cell` and `sparkline` are set.\n- Sparklines are decorative SVG with an `aria-label` summarising the series\n (point count + last value). For a screen-reader-friendly exact readout,\n pair the chart column with a plain numeric column.\n- The geometry helper is exported as `buildSparkline(values, config)` if you\n want to render the same chart outside a grid cell.\n\nSee the live [Sparkline cells](https://svgrid.com/demos/140-sparkline-cells/)\ndemo.\n"
3105
3126
  },
3106
3127
  {
3107
3128
  "slug": "help/cells/styling-cells",
@@ -3119,25 +3140,25 @@ export const docs = [
3119
3140
  "slug": "help/cells/tooltips",
3120
3141
  "path": "docs/help/cells/tooltips.md",
3121
3142
  "title": "Tooltips",
3122
- "markdown": "# Tooltips\n\nThere is no built-in tooltip API on `ColumnDef`. Use the standard `title`\nattribute, an accessible `<dialog>`, or any popover library - wired\nthrough a custom cell renderer.\n\n<div data-docs-demo=\"85-tooltips-and-notes\" data-height=\"480\"></div>\n\n## With `title` (no JS, screen-reader friendly)\n\n```ts\n{\n field: 'description',\n cell: (ctx) => renderSnippet(EllipsisCell, {\n value: String(ctx.getValue() ?? ''),\n }),\n}\n```\n\n```svelte\n{#snippet EllipsisCell(p: { value: string })}\n <span title={p.value} class=\"block truncate\">{p.value}</span>\n{/snippet}\n```\n\n`title` is the safest default: screen readers announce it; mouse users see\na tooltip; keyboard users see it on focus (when wrapped in a focusable\nelement).\n\n## With a popover library\n\nPass a component instead of a snippet:\n\n```ts\nimport TooltipCell from './TooltipCell.svelte'\nimport { renderComponent } from '@svgrid/grid'\n\n{\n field: 'description',\n cell: (ctx) => renderComponent(TooltipCell, {\n value: String(ctx.getValue() ?? ''),\n tip: ctx.row.original.fullDescription,\n }),\n}\n```\n\n## Header tooltips\n\nThe same pattern, but via the `header:` field - see\n[Custom header components](../columns/custom-header-components.md).\n\n## Gotchas\n\n- The grid's column-menu popover and the cell-edit overlay use top-layer\n z-indices around 100. Your tooltip should be either lower (so it slides\n under those overlays when both open) or higher with a click-outside\n dismissal.\n- A long tooltip inside an Excel-style filter dropdown can occlude the\n filter input. Detach the tooltip from cells inside an open filter menu.\n\n## See also\n\n- [Cell components](./cell-components.md)\n"
3143
+ "markdown": "# Tooltips\n\nThere is no built-in tooltip API on `ColumnDef`. Use the standard `title`\nattribute, an accessible `<dialog>`, or any popover library - wired\nthrough a custom cell renderer.\n\n<div data-docs-demo=\"85-tooltips-and-notes\" data-height=\"480\"></div>\n\n## With `title` (no JS, screen-reader friendly)\n\n```ts\n{\n field: 'description',\n cell: (ctx) => renderSnippet(EllipsisCell, {\n value: String(ctx.getValue() ?? ''),\n }),\n}\n```\n\n```svelte\n{#snippet EllipsisCell(p: { value: string })}\n <span title={p.value} class=\"block truncate\">{p.value}</span>\n{/snippet}\n```\n\n`title` is the safest default: screen readers announce it; mouse users see\na tooltip; keyboard users see it on focus (when wrapped in a focusable\nelement).\n\n## With a popover library\n\nPass a component instead of a snippet:\n\n```ts\nimport TooltipCell from './TooltipCell.svelte'\nimport { renderComponent } from '@svgrid/grid'\n\n{\n field: 'description',\n cell: (ctx) => renderComponent(TooltipCell, {\n value: String(ctx.getValue() ?? ''),\n tip: ctx.row.original.fullDescription,\n }),\n}\n```\n\n## Header tooltips\n\nThe same pattern, but via the `header:` field - see\n[Custom header components](../columns/custom-header-components.md).\n\n## Gotchas\n\n- The grid's column-menu popover and the cell-edit overlay use top-layer\n z-indices around 100. Your tooltip should be either lower (so it slides\n under those overlays when both open) or higher with a click-outside\n dismissal.\n- A long tooltip inside an Excel-style filter dropdown can occlude the\n filter input. Detach the tooltip from cells inside an open filter menu.\n\n## See also\n\n- [Cell components](./cell-components.md)\n"
3123
3144
  },
3124
3145
  {
3125
3146
  "slug": "help/cells/view-refresh",
3126
3147
  "path": "docs/help/cells/view-refresh.md",
3127
3148
  "title": "View refresh",
3128
- "markdown": "# View refresh\n\nThe grid renders reactively - it does **not** have a `refresh()` method,\nbecause it doesn't need one. To make the grid re-display, change the data\nthat drives it.\n\n## Forcing a refresh\n\n| You want | Do this |\n| -------- | ------- |\n| Re-display every row | Reassign `data` to a new array (`rows = [...rows]`). |\n| Re-display one row | Mutate the row through a `$state` array, or call `api.setCellValue(...)`. |\n| Re-apply sort / filter / page | Update the controlled state slice (or reassign the data). |\n| Re-render a single cell | The grid's renderer keys on the cell's `cellId`. Changing the underlying value re-renders the cell. |\n\n## When the grid does NOT re-render\n\nIf you mutate a row object **deeply** without going through `$state` -\ne.g. `someExternalRef.salary = 50000` where `someExternalRef` is an object\nheld outside the grid - Svelte 5 will not know to update.\n\nTwo safe patterns:\n\n```svelte\n<script lang=\"ts\">\n let rows = $state<Person[]>(initial)\n\n // āœ… via $state proxy\n rows[0]!.salary = 50_000\n\n // āœ… via array reassignment\n rows = rows.map((r, i) => (i === 0 ? { ...r, salary: 50_000 } : r))\n\n // āœ… via API\n api?.setCellValue(0, 'salary', 50_000)\n</script>\n```\n\n## Refresh-after-async\n\nFor data fetched asynchronously, the array reassignment is the canonical\ntrigger:\n\n```svelte\n<script lang=\"ts\">\n let rows = $state<Person[]>([])\n $effect(() => { fetchRows().then((next) => (rows = next)) })\n</script>\n```\n\n## See also\n\n- [Row data](../rows/row-data.md)\n- [Accessing rows](../rows/accessing-rows.md)\n"
3149
+ "markdown": "# View refresh\n\nThe grid renders reactively - it does **not** have a `refresh()` method,\nbecause it doesn't need one. To make the grid re-display, change the data\nthat drives it.\n\n## Forcing a refresh\n\n| You want | Do this |\n| -------- | ------- |\n| Re-display every row | Reassign `data` to a new array (`rows = [...rows]`). |\n| Re-display one row | Mutate the row through a `$state` array, or call `api.setCellValue(...)`. |\n| Re-apply sort / filter / page | Update the controlled state slice (or reassign the data). |\n| Re-render a single cell | The grid's renderer keys on the cell's `cellId`. Changing the underlying value re-renders the cell. |\n\n## When the grid does NOT re-render\n\nIf you mutate a row object **deeply** without going through `$state` -\ne.g. `someExternalRef.salary = 50000` where `someExternalRef` is an object\nheld outside the grid - Svelte 5 will not know to update.\n\nTwo safe patterns:\n\n```svelte\n<script lang=\"ts\">\n let rows = $state<Person[]>(initial)\n\n // āœ… via $state proxy\n rows[0]!.salary = 50_000\n\n // āœ… via array reassignment\n rows = rows.map((r, i) => (i === 0 ? { ...r, salary: 50_000 } : r))\n\n // āœ… via API\n api?.setCellValue(0, 'salary', 50_000)\n</script>\n```\n\n## Refresh-after-async\n\nFor data fetched asynchronously, the array reassignment is the canonical\ntrigger:\n\n```svelte\n<script lang=\"ts\">\n let rows = $state<Person[]>([])\n $effect(() => { fetchRows().then((next) => (rows = next)) })\n</script>\n```\n\n## See also\n\n- [Row data](../rows/row-data.md)\n- [Accessing rows](../rows/accessing-rows.md)\n"
3129
3150
  },
3130
3151
  {
3131
3152
  "slug": "help/charts",
3132
3153
  "path": "docs/help/charts.md",
3133
3154
  "title": "Integrated charts",
3134
- "markdown": "# Integrated charts\r\n\r\nSvGrid can chart its own data with no external charting library. Two pieces:\r\n\r\n- **`SvGridChart`** - a component that renders a `ChartSpec` as inline SVG\r\n (bar, line, area, pie, scatter / bubble) with axes, hover tooltips, a\r\n clickable legend that toggles series (or pie slices) on and off, reference\r\n lines, a time axis, and a visually-hidden data table for screen readers.\r\n- **`rowsToChartSpec(rows, opts)`** - aggregates flat rows (group by a\r\n category field, reduce a value field) into a `ChartSpec`, with optional\r\n sorting and top-N + \"Other\" bucketing.\r\n\r\nFeed it `api.getDisplayedRows()` and the chart reflects the grid's current,\r\nfiltered, sorted data - the \"chart from the grid\" enterprise feature.\r\n\r\n![The grid's filtered and sorted rows flow through rowsToChartSpec into SvGridChart, which re-renders whenever the grid's filters or sorting change.](/docs-media/grid-charts.svg)\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, SvGridChart, rowsToChartSpec, type SvGridApi } from '@svgrid/grid'\r\n\r\n let api: SvGridApi<F, Row> | null = null\r\n let displayed = $state<Row[]>(rows)\r\n const sync = () => (displayed = (api?.getDisplayedRows() as Row[]) ?? rows)\r\n\r\n const spec = $derived(\r\n rowsToChartSpec(displayed, { type: 'bar', category: 'region', value: 'revenue', reduce: 'sum' }),\r\n )\r\n</script>\r\n\r\n<SvGrid {data} {columns} {features} sortable filterable\r\n onApiReady={(a) => { api = a; sync() }}\r\n onFiltersChange={sync} onSortingChange={sync} />\r\n\r\n<SvGridChart {spec} />\r\n```\r\n\r\n## `rowsToChartSpec`\r\n\r\n| Option | Meaning |\r\n| ------------- | -------------------------------------------------------- |\r\n| `type` | `'bar' \\| 'line' \\| 'area' \\| 'pie' \\| 'scatter'` |\r\n| `category` | Field whose distinct values become the x-axis / slices. |\r\n| `value` | Numeric field, **or an array of fields** (one series each). |\r\n| `series` | Pivot field: one series per distinct value of it. |\r\n| `reduce` | `'sum'` (default), `'avg'`, or `'count'`. |\r\n| `stacked` | Stack the series instead of grouping them. |\r\n| `stacked100` | Stack to 100% - each category normalized to its total. |\r\n| `sort` | `'value-desc' \\| 'value-asc' \\| 'category' \\| 'none'`. |\r\n| `topN` | Keep the top N categories, bucket the rest into \"Other\". |\r\n| `otherLabel` | Label for the bucket (default `'Other'`). |\r\n| `width` / `height` | SVG viewBox size. |\r\n\r\nThree multi-series shapes:\r\n\r\n```ts\r\nrowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'revenue' }) // 1 series\r\nrowsToChartSpec(rows, { type: 'bar', category: 'region', value: ['revenue', 'cost'] }) // 2 series\r\nrowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'sales', series: 'product' }) // pivot\r\n```\r\n\r\n## Building a spec yourself\r\n\r\n`SvGridChart` takes any `ChartSpec`. Per-series `type` and `axis` give you combo\r\ncharts and a secondary Y axis; `stacked` stacks bars/areas; `innerRadius` turns a\r\npie into a donut; `yAxisTitle` / `y2AxisTitle` / `xAxisTitle` label the axes.\r\nNegative values drop below a zero baseline automatically, and `null` / `NaN`\r\nvalues break the line (a gap) instead of dropping it to zero.\r\n\r\n```ts\r\nconst spec: ChartSpec = {\r\n type: 'bar',\r\n stacked: false,\r\n categories: ['Q1', 'Q2', 'Q3', 'Q4'],\r\n series: [\r\n { label: 'Revenue', values: [120, 140, 90, 180] }, // bars, left axis\r\n { label: 'Margin %', values: [0.31, 0.28, 0.22, 0.35], type: 'line', axis: 'right' }, // line, right axis\r\n ],\r\n}\r\n// donut: { type: 'pie', innerRadius: 0.6, categories, series: [...] }\r\n```\r\n\r\nThe geometry helper `buildChart(spec)` is exported too, if you want the raw SVG\r\nprimitives for a custom renderer.\r\n\r\n## Reference / target lines\r\n\r\n`referenceLines` draws horizontal goal / average / SLA lines across the plot.\r\nEach entry stretches the axis domain so the line is always in view:\r\n\r\n```ts\r\nconst spec: ChartSpec = {\r\n type: 'bar', categories: ['Q1', 'Q2', 'Q3', 'Q4'],\r\n series: [{ label: 'Revenue', values: [120, 140, 90, 180] }],\r\n referenceLines: [{ value: 150, label: 'Target', axis: 'left', color: '#ef4444', dashed: true }],\r\n}\r\n```\r\n\r\n## 100% stacked\r\n\r\n`stacked100: true` (implies `stacked`) normalizes each category to its own\r\ntotal, so the axis runs 0..100% and every column fills the plot height -\r\nideal for reading composition / share. Tooltips and labels still show the\r\noriginal values.\r\n\r\n## Scatter / bubble\r\n\r\n`type: 'scatter'` plots two numeric measures against each other. Each series\r\ncarries `points: [{ x, y, r?, label? }]`; an optional `r` becomes the bubble\r\nradius (scaled across the data). One series per group colours the points.\r\n\r\n```ts\r\nconst spec: ChartSpec = {\r\n type: 'scatter', categories: [],\r\n xAxisTitle: 'Spend', yAxisTitle: 'Revenue',\r\n series: [\r\n { label: 'EMEA', values: [], points: [{ x: 12_000, y: 80_000, r: 18, label: 'Ada' }] },\r\n { label: 'APAC', values: [], points: [{ x: 30_000, y: 140_000, r: 33, label: 'Grace' }] },\r\n ],\r\n}\r\n```\r\n\r\n## Horizontal bars\r\n\r\n`orientation: 'horizontal'` swaps the axes: categories run down the left, bars\r\ngrow rightward. It suits long category labels (rep names, product names) that\r\nwould otherwise crowd / rotate on a vertical x-axis. Grouped, stacked, 100%,\r\ndata labels, and reference lines (which become vertical) all work. Only applies\r\nwhen every series is a bar - combo / line / area fall back to vertical.\r\n\r\n```ts\r\nconst spec: ChartSpec = {\r\n type: 'bar', orientation: 'horizontal',\r\n categories: ['Ada', 'Grace', 'Margaret', 'Linus'],\r\n series: [{ label: 'Revenue', values: [120, 90, 140, 80] }],\r\n referenceLines: [{ value: 110, label: 'Avg' }], // drawn as a vertical line\r\n}\r\n```\r\n\r\n## Time axis\r\n\r\n`xType: 'time'` treats `categories` as dates: x positions are spaced by actual\r\ntime (irregular gaps render proportionally, not evenly) and the axis shows real\r\ndate ticks. Works with line / area / bar.\r\n\r\n```ts\r\nrowsToChartSpec(rows, { type: 'line', category: 'date', value: 'sessions', series: 'channel' })\r\n// then: spec.xType = 'time'\r\n```\r\n\r\n## Interactivity\r\n\r\n`SvGridChart` is interactive by default:\r\n\r\n- **Unified tooltip + crosshair** - hovering a category column shows a vertical\r\n crosshair and a single tooltip listing **every** series' value at that\r\n category (with color swatches), so multi-series and combo charts read at a\r\n glance. Pie slices keep a per-slice tooltip.\r\n- **Legend toggle + isolate** - clicking a legend chip hides/shows that series\r\n (or pie slice); **double-clicking** isolates it (shows only that one, click\r\n again to restore). Hovering a chip dims the others. The chart re-scales to\r\n the visible data; colors stay stable.\r\n- **Scatter tooltip** - hovering a bubble shows its x / y (and label).\r\n- **Legend overflow** - a wide pivot (many series) collapses the legend to the\r\n first 10 chips with a \"+N more\" toggle, so it never floods the chart.\r\n- **Data labels** - `dataLabels` draws the value on each bar / point / slice.\r\n- **Drill-down** - `onSelect({ category, series, value })` fires when a bar /\r\n point / slice is clicked. Wire it to `api.setFacetFilter(...)` to filter the\r\n grid to the clicked category - the \"click the chart to drill the grid\" loop.\r\n\r\n```svelte\r\n<SvGridChart {spec}\r\n dataLabels // value labels on each element\r\n formatValue={(v) => `$${compact(v)}`} // tooltips, labels, AND Y-axis ticks\r\n onSelect={(s) => api.setFacetFilter('region', [s.category])} // drill the grid\r\n legend={true} // clickable legend; default true\r\n interactive={false} // opt out of tooltips + toggling\r\n/>\r\n```\r\n\r\n`formatValue` is applied to tooltips, data labels, **and the Y-axis ticks**, so\r\nthey stay consistent - keep it compact (e.g. `$2M`, not `$2,000,000`).\r\n\r\n## Export\r\n\r\nDownload the rendered chart as a standalone SVG or a PNG. Pass the chart's\r\nwrapper element (or its `<svg>`):\r\n\r\n```svelte\r\n<div bind:this={chartEl}><SvGridChart {spec} /></div>\r\n\r\n<button onclick={() => downloadChartSvg(chartEl, 'chart.svg')}>SVG</button>\r\n<button onclick={() => downloadChartPng(chartEl, 'chart.png', { scale: 2 })}>PNG</button>\r\n```\r\n\r\n`chartToSvgString` / `chartToPngBlob` return the data if you want to upload it\r\ninstead. The export inlines the live theme colors, so it matches what's on\r\nscreen.\r\n\r\n## Notes\r\n\r\n- Pure SVG - no canvas, no dependency, SSR-safe, and it inherits the grid's\r\n `--sg-*` theme tokens.\r\n- **Accessible** - every chart renders a visually-hidden `<table>` of the same\r\n data, wired to the SVG via `aria-describedby`, so screen readers get the\r\n numbers, not just \"chart\".\r\n- For a richer charting stack (zoom, tooltips, dozens of types) you can still\r\n pipe `getDisplayedRows()` into Chart.js or a web component - see demos\r\n `73-chartjs-sync` and `77-smart-chart`. `SvGridChart` is the\r\n batteries-included option.\r\n\r\nSee the live [Integrated charts](https://sv-grid.com/demos/147-integrated-charts)\r\ndemo, or the [Chart wizard panel](https://sv-grid.com/demos/152-chart-wizard) -\r\na pick-a-chart dialog whose type-gallery thumbnails are themselves live\r\n`SvGridChart` previews.\r\n"
3155
+ "markdown": "# Integrated charts\n\nSvGrid can chart its own data with no external charting library. Two pieces:\n\n- **`SvGridChart`** - a component that renders a `ChartSpec` as inline SVG\n (bar, line, area, pie, scatter / bubble) with axes, hover tooltips, a\n clickable legend that toggles series (or pie slices) on and off, reference\n lines, a time axis, and a visually-hidden data table for screen readers.\n- **`rowsToChartSpec(rows, opts)`** - aggregates flat rows (group by a\n category field, reduce a value field) into a `ChartSpec`, with optional\n sorting and top-N + \"Other\" bucketing.\n\nFeed it `api.getDisplayedRows()` and the chart reflects the grid's current,\nfiltered, sorted data - the \"chart from the grid\" enterprise feature.\n\n![The grid's filtered and sorted rows flow through rowsToChartSpec into SvGridChart, which re-renders whenever the grid's filters or sorting change.](/docs-media/grid-charts.svg)\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, SvGridChart, rowsToChartSpec, type SvGridApi } from '@svgrid/grid'\n\n let api: SvGridApi<F, Row> | null = null\n let displayed = $state<Row[]>(rows)\n const sync = () => (displayed = (api?.getDisplayedRows() as Row[]) ?? rows)\n\n const spec = $derived(\n rowsToChartSpec(displayed, { type: 'bar', category: 'region', value: 'revenue', reduce: 'sum' }),\n )\n</script>\n\n<SvGrid {data} {columns} {features} sortable filterable\n onApiReady={(a) => { api = a; sync() }}\n onFiltersChange={sync} onSortingChange={sync} />\n\n<SvGridChart {spec} />\n```\n\n## `rowsToChartSpec`\n\n| Option | Meaning |\n| ------------- | -------------------------------------------------------- |\n| `type` | `'bar' \\| 'line' \\| 'area' \\| 'pie' \\| 'scatter'` |\n| `category` | Field whose distinct values become the x-axis / slices. |\n| `value` | Numeric field, **or an array of fields** (one series each). |\n| `series` | Pivot field: one series per distinct value of it. |\n| `reduce` | `'sum'` (default), `'avg'`, or `'count'`. |\n| `stacked` | Stack the series instead of grouping them. |\n| `stacked100` | Stack to 100% - each category normalized to its total. |\n| `sort` | `'value-desc' \\| 'value-asc' \\| 'category' \\| 'none'`. |\n| `topN` | Keep the top N categories, bucket the rest into \"Other\". |\n| `otherLabel` | Label for the bucket (default `'Other'`). |\n| `width` / `height` | SVG viewBox size. |\n\nThree multi-series shapes:\n\n```ts\nrowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'revenue' }) // 1 series\nrowsToChartSpec(rows, { type: 'bar', category: 'region', value: ['revenue', 'cost'] }) // 2 series\nrowsToChartSpec(rows, { type: 'bar', category: 'region', value: 'sales', series: 'product' }) // pivot\n```\n\n## Building a spec yourself\n\n`SvGridChart` takes any `ChartSpec`. Per-series `type` and `axis` give you combo\ncharts and a secondary Y axis; `stacked` stacks bars/areas; `innerRadius` turns a\npie into a donut; `yAxisTitle` / `y2AxisTitle` / `xAxisTitle` label the axes.\nNegative values drop below a zero baseline automatically, and `null` / `NaN`\nvalues break the line (a gap) instead of dropping it to zero.\n\n```ts\nconst spec: ChartSpec = {\n type: 'bar',\n stacked: false,\n categories: ['Q1', 'Q2', 'Q3', 'Q4'],\n series: [\n { label: 'Revenue', values: [120, 140, 90, 180] }, // bars, left axis\n { label: 'Margin %', values: [0.31, 0.28, 0.22, 0.35], type: 'line', axis: 'right' }, // line, right axis\n ],\n}\n// donut: { type: 'pie', innerRadius: 0.6, categories, series: [...] }\n```\n\nThe geometry helper `buildChart(spec)` is exported too, if you want the raw SVG\nprimitives for a custom renderer.\n\n## Reference / target lines\n\n`referenceLines` draws horizontal goal / average / SLA lines across the plot.\nEach entry stretches the axis domain so the line is always in view:\n\n```ts\nconst spec: ChartSpec = {\n type: 'bar', categories: ['Q1', 'Q2', 'Q3', 'Q4'],\n series: [{ label: 'Revenue', values: [120, 140, 90, 180] }],\n referenceLines: [{ value: 150, label: 'Target', axis: 'left', color: '#ef4444', dashed: true }],\n}\n```\n\n## 100% stacked\n\n`stacked100: true` (implies `stacked`) normalizes each category to its own\ntotal, so the axis runs 0..100% and every column fills the plot height -\nideal for reading composition / share. Tooltips and labels still show the\noriginal values.\n\n## Scatter / bubble\n\n`type: 'scatter'` plots two numeric measures against each other. Each series\ncarries `points: [{ x, y, r?, label? }]`; an optional `r` becomes the bubble\nradius (scaled across the data). One series per group colours the points.\n\n```ts\nconst spec: ChartSpec = {\n type: 'scatter', categories: [],\n xAxisTitle: 'Spend', yAxisTitle: 'Revenue',\n series: [\n { label: 'EMEA', values: [], points: [{ x: 12_000, y: 80_000, r: 18, label: 'Ada' }] },\n { label: 'APAC', values: [], points: [{ x: 30_000, y: 140_000, r: 33, label: 'Grace' }] },\n ],\n}\n```\n\n## Horizontal bars\n\n`orientation: 'horizontal'` swaps the axes: categories run down the left, bars\ngrow rightward. It suits long category labels (rep names, product names) that\nwould otherwise crowd / rotate on a vertical x-axis. Grouped, stacked, 100%,\ndata labels, and reference lines (which become vertical) all work. Only applies\nwhen every series is a bar - combo / line / area fall back to vertical.\n\n```ts\nconst spec: ChartSpec = {\n type: 'bar', orientation: 'horizontal',\n categories: ['Ada', 'Grace', 'Margaret', 'Linus'],\n series: [{ label: 'Revenue', values: [120, 90, 140, 80] }],\n referenceLines: [{ value: 110, label: 'Avg' }], // drawn as a vertical line\n}\n```\n\n## Time axis\n\n`xType: 'time'` treats `categories` as dates: x positions are spaced by actual\ntime (irregular gaps render proportionally, not evenly) and the axis shows real\ndate ticks. Works with line / area / bar.\n\n```ts\nrowsToChartSpec(rows, { type: 'line', category: 'date', value: 'sessions', series: 'channel' })\n// then: spec.xType = 'time'\n```\n\n## Interactivity\n\n`SvGridChart` is interactive by default:\n\n- **Unified tooltip + crosshair** - hovering a category column shows a vertical\n crosshair and a single tooltip listing **every** series' value at that\n category (with color swatches), so multi-series and combo charts read at a\n glance. Pie slices keep a per-slice tooltip.\n- **Legend toggle + isolate** - clicking a legend chip hides/shows that series\n (or pie slice); **double-clicking** isolates it (shows only that one, click\n again to restore). Hovering a chip dims the others. The chart re-scales to\n the visible data; colors stay stable.\n- **Scatter tooltip** - hovering a bubble shows its x / y (and label).\n- **Legend overflow** - a wide pivot (many series) collapses the legend to the\n first 10 chips with a \"+N more\" toggle, so it never floods the chart.\n- **Data labels** - `dataLabels` draws the value on each bar / point / slice.\n- **Drill-down** - `onSelect({ category, series, value })` fires when a bar /\n point / slice is clicked. Wire it to `api.setFacetFilter(...)` to filter the\n grid to the clicked category - the \"click the chart to drill the grid\" loop.\n\n```svelte\n<SvGridChart {spec}\n dataLabels // value labels on each element\n formatValue={(v) => `$${compact(v)}`} // tooltips, labels, AND Y-axis ticks\n onSelect={(s) => api.setFacetFilter('region', [s.category])} // drill the grid\n legend={true} // clickable legend; default true\n interactive={false} // opt out of tooltips + toggling\n/>\n```\n\n`formatValue` is applied to tooltips, data labels, **and the Y-axis ticks**, so\nthey stay consistent - keep it compact (e.g. `$2M`, not `$2,000,000`).\n\n## Export\n\nDownload the rendered chart as a standalone SVG or a PNG. Pass the chart's\nwrapper element (or its `<svg>`):\n\n```svelte\n<div bind:this={chartEl}><SvGridChart {spec} /></div>\n\n<button onclick={() => downloadChartSvg(chartEl, 'chart.svg')}>SVG</button>\n<button onclick={() => downloadChartPng(chartEl, 'chart.png', { scale: 2 })}>PNG</button>\n```\n\n`chartToSvgString` / `chartToPngBlob` return the data if you want to upload it\ninstead. The export inlines the live theme colors, so it matches what's on\nscreen.\n\n## Notes\n\n- Pure SVG - no canvas, no dependency, SSR-safe, and it inherits the grid's\n `--sg-*` theme tokens.\n- **Accessible** - every chart renders a visually-hidden `<table>` of the same\n data, wired to the SVG via `aria-describedby`, so screen readers get the\n numbers, not just \"chart\".\n- For a richer charting stack (zoom, tooltips, dozens of types) you can still\n pipe `getDisplayedRows()` into Chart.js or a web component - see demos\n `73-chartjs-sync` and `77-smart-chart`. `SvGridChart` is the\n batteries-included option.\n\nSee the live [Integrated charts](https://svgrid.com/demos/147-integrated-charts/)\ndemo, or the [Chart wizard panel](https://svgrid.com/demos/152-chart-wizard/) -\na pick-a-chart dialog whose type-gallery thumbnails are themselves live\n`SvGridChart` previews.\n"
3135
3156
  },
3136
3157
  {
3137
3158
  "slug": "help/collaboration",
3138
3159
  "path": "docs/help/collaboration.md",
3139
3160
  "title": "Real-time collaboration",
3140
- "markdown": "# Real-time collaboration\n\nTwo people (or two AI agents) on the same grid: **presence** (who's here and\nwhere their cursor is) and **live edits** (a change in one client appears in\nevery other). SvGrid packages this as a headless controller over a pluggable\ntransport - the only infrastructure-specific piece.\n\n```ts\nimport { createCollaboration, broadcastChannelTransport } from '@svgrid/grid'\n\nconst collab = createCollaboration({\n user: { id: myId, name: 'Ada', color: '#ef4444' },\n transport: broadcastChannelTransport('my-grid-room'),\n onPeersChange: (peers) => renderCursors(peers),\n onRemoteEdit: ({ rowId, columnId, value }) => applyEdit(rowId, columnId, value),\n})\n```\n\nWire it to the grid:\n\n```svelte\n<SvGrid {data} {columns} editable getRowId={(r) => r.id}\n onActiveCellChange={(c) => collab.setCell({ rowId: data[c.rowIndex].id, columnId: c.columnId })}\n onCellValueChange={(e) => collab.sendEdit(data[e.rowIndex].id, e.columnId, e.newValue)} />\n```\n\n## The transport\n\nThe controller is transport-agnostic. It ships with one adapter:\n\n- **`broadcastChannelTransport(name)`** - syncs across tabs of the same\n browser with **zero backend**. Great for demos and single-user multi-tab.\n\nFor cross-machine collaboration implement `CollabTransport` (a `post(msg)` +\n`subscribe(handler)` pair) over a WebSocket, WebRTC datachannel, or a CRDT\nlibrary:\n\n```ts\nconst wsTransport: CollabTransport = {\n post: (msg) => socket.send(JSON.stringify(msg)),\n subscribe: (h) => { const l = (e) => h(JSON.parse(e.data)); socket.addEventListener('message', l); return () => socket.removeEventListener('message', l) },\n}\n```\n\n## The controller API\n\n| Method | Does |\n| --------------------------- | ----------------------------------------------- |\n| `setCell(cell \\| null)` | Broadcast where your cursor is. |\n| `sendEdit(rowId, col, val)` | Broadcast a cell edit. |\n| `peers()` | Present peers (excludes you), with their cursor.|\n| `dispose()` | Announce leave + tear down (call on unmount). |\n\n`onPeersChange` fires whenever the peer set or any cursor moves; `onRemoteEdit`\nfires for edits from **other** users only (never echoes your own).\n\n## Notes\n\n- Presence is heartbeat-pruned: a peer that closes its tab without a clean\n `bye` is dropped after `peerTimeoutMs` (default 15s).\n- Edits are last-writer-wins at the cell level. For conflict-free merging on a\n busy doc, back the transport with a CRDT; the controller doesn't assume one.\n- This is also the **multi-agent** substrate: an AI agent is just another peer\n posting `edit` messages - drive `sendEdit` from your agent loop.\n\nSee the live [Real-time collaboration](https://sv-grid.com/demos/149-realtime-collaboration)\ndemo (open it in two tabs).\n"
3161
+ "markdown": "# Real-time collaboration\n\nTwo people (or two AI agents) on the same grid: **presence** (who's here and\nwhere their cursor is) and **live edits** (a change in one client appears in\nevery other). SvGrid packages this as a headless controller over a pluggable\ntransport - the only infrastructure-specific piece.\n\n```ts\nimport { createCollaboration, broadcastChannelTransport } from '@svgrid/grid'\n\nconst collab = createCollaboration({\n user: { id: myId, name: 'Ada', color: '#ef4444' },\n transport: broadcastChannelTransport('my-grid-room'),\n onPeersChange: (peers) => renderCursors(peers),\n onRemoteEdit: ({ rowId, columnId, value }) => applyEdit(rowId, columnId, value),\n})\n```\n\nWire it to the grid:\n\n```svelte\n<SvGrid {data} {columns} editable getRowId={(r) => r.id}\n onActiveCellChange={(c) => collab.setCell({ rowId: data[c.rowIndex].id, columnId: c.columnId })}\n onCellValueChange={(e) => collab.sendEdit(data[e.rowIndex].id, e.columnId, e.newValue)} />\n```\n\n## The transport\n\nThe controller is transport-agnostic. It ships with one adapter:\n\n- **`broadcastChannelTransport(name)`** - syncs across tabs of the same\n browser with **zero backend**. Great for demos and single-user multi-tab.\n\nFor cross-machine collaboration implement `CollabTransport` (a `post(msg)` +\n`subscribe(handler)` pair) over a WebSocket, WebRTC datachannel, or a CRDT\nlibrary:\n\n```ts\nconst wsTransport: CollabTransport = {\n post: (msg) => socket.send(JSON.stringify(msg)),\n subscribe: (h) => { const l = (e) => h(JSON.parse(e.data)); socket.addEventListener('message', l); return () => socket.removeEventListener('message', l) },\n}\n```\n\n## The controller API\n\n| Method | Does |\n| --------------------------- | ----------------------------------------------- |\n| `setCell(cell \\| null)` | Broadcast where your cursor is. |\n| `sendEdit(rowId, col, val)` | Broadcast a cell edit. |\n| `peers()` | Present peers (excludes you), with their cursor.|\n| `dispose()` | Announce leave + tear down (call on unmount). |\n\n`onPeersChange` fires whenever the peer set or any cursor moves; `onRemoteEdit`\nfires for edits from **other** users only (never echoes your own).\n\n## Notes\n\n- Presence is heartbeat-pruned: a peer that closes its tab without a clean\n `bye` is dropped after `peerTimeoutMs` (default 15s).\n- Edits are last-writer-wins at the cell level. For conflict-free merging on a\n busy doc, back the transport with a CRDT; the controller doesn't assume one.\n- This is also the **multi-agent** substrate: an AI agent is just another peer\n posting `edit` messages - drive `sendEdit` from your agent loop.\n\nSee the live [Real-time collaboration](https://svgrid.com/demos/149-realtime-collaboration/)\ndemo (open it in two tabs).\n"
3141
3162
  },
3142
3163
  {
3143
3164
  "slug": "help/columns-hierarchy",
@@ -3155,13 +3176,13 @@ export const docs = [
3155
3176
  "slug": "help/columns/column-definitions",
3156
3177
  "path": "docs/help/columns/column-definitions.md",
3157
3178
  "title": "Column definitions",
3158
- "markdown": "# Column definitions\r\n\r\nA `ColumnDef` tells SvGrid how to read a value out of a row, how to render it,\r\nand which features apply to it. The grid below is built from a handful\r\nof `ColumnDef`s - look at the [source](https://svgrid.com/demos/01-quick-start/) to see how each shape maps to a column behaviour:\r\n\r\n<div data-docs-demo=\"01-quick-start\" data-height=\"460\"></div>\r\n\r\n\r\n## Minimal\r\n\r\n```ts\r\nimport type { ColumnDef } from '@svgrid/grid'\r\n\r\ntype Person = { firstName: string; age: number; status: string }\r\n\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName', header: 'First name' },\r\n { field: 'age', header: 'Age' },\r\n { field: 'status', header: 'Status' },\r\n]\r\n```\r\n\r\n## Properties\r\n\r\n| Property | Type | Purpose |\r\n| --- | --- | --- |\r\n| `id` | `string` | Stable column id. Required when you use `fieldFn` and no `field`. |\r\n| `field` | `keyof TData & string` | Reads `row[key]`. |\r\n| `fieldFn` | `(row) => unknown` | Computes the value. |\r\n| `header` | `string` \\| `(ctx) => unknown` | String, or a function returning a `renderSnippet` / `renderComponent`. |\r\n| `cell` | `(ctx) => unknown` | Same shape as `header`, for body cells. |\r\n| `footer` | `string` \\| `(ctx) => unknown` | Footer cell. |\r\n| `editorType` | `'text' \\| 'number' \\| 'date' \\| 'datetime' \\| 'checkbox'` | Inline editor type. |\r\n| `format` | `CellFormatConfig` | Built-in `number`, `currency`, `percent`, `date`, `datetime` formatters. |\r\n| `formatter` | `(ctx) => string` | Custom formatter - runs after `field` / `fieldFn`. |\r\n| `columns` | `ColumnDef[]` | Children - turns this column into a column **group**. |\r\n| `width` | `number` | Initial width in pixels (overrides the grid's `columnWidth`). |\r\n\r\nSee [`packages/grid/src/core.ts`](../../../packages/grid/src/core.ts).\r\n\r\n## Accessor vs. fieldFn\r\n\r\n`field` is the common case. Use `fieldFn` when the value is\r\ncomputed or comes from a nested object:\r\n\r\n```ts\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName', header: 'First' },\r\n {\r\n id: 'fullName',\r\n header: 'Full name',\r\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\r\n },\r\n]\r\n```\r\n\r\nWhenever you use `fieldFn` you must supply an `id` - there is no string key\r\nto derive one from.\r\n\r\n## Format vs. formatter vs. cell\r\n\r\n| You want | Use |\r\n| -------- | --- |\r\n| Locale-aware number / currency / percent / date | `format` |\r\n| A custom string transformation | `formatter` |\r\n| Custom HTML (avatars, pills, progress, sparklines) | `cell` with `renderSnippet` |\r\n\r\n`format` is purely declarative and locale-aware - prefer it for anything\r\nnumeric or temporal:\r\n\r\n```ts\r\n{ field: 'salary', header: 'Salary',\r\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } }\r\n\r\n{ field: 'joinedAt', header: 'Joined',\r\n format: { type: 'date', pattern: 'y-m-d' } }\r\n\r\n{ field: 'utilization', header: 'Utilization',\r\n format: { type: 'percent', valueIsPercentPoints: true } } // 42 -> 42%\r\n```\r\n\r\n`formatter` runs after the accessor; the result is what gets displayed\r\n(and what gets copied to the clipboard during cell selection).\r\n\r\n`cell` is the most powerful - see [Cell components](../cells/cell-components.md).\r\n\r\n## TypeScript\r\n\r\nPass the row type as the second generic; the column's `field` is then\r\nchecked against the row's keys:\r\n\r\n```ts\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName' }, // āœ…\r\n // { field: 'first_name' } // āœ— compile error\r\n]\r\n```\r\n\r\nThe first generic is the **feature set** - derive it from `tableFeatures` so\r\nfeature-specific column properties light up:\r\n\r\n```ts\r\nconst features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\r\ntype Features = typeof features\r\n\r\nconst columns: ColumnDef<Features, Person>[] = [/* … */]\r\n```\r\n\r\n## See also\r\n\r\n- [Updating definitions](./updating-definitions.md)\r\n- [Column state](./column-state.md)\r\n- [Custom header components](./custom-header-components.md)\r\n- Example: [`02-sort-filter-paginate.svelte`](../../../examples/src/demos/02-sort-filter-paginate.svelte)\r\n"
3179
+ "markdown": "# Column definitions\r\n\r\nA `ColumnDef` tells SvGrid how to read a value out of a row, how to render it,\r\nand which features apply to it. The grid below is built from a handful\r\nof `ColumnDef`s - look at the [source](https://svgrid.com/demos/01-quick-start/) to see how each shape maps to a column behaviour:\r\n\r\n<div data-docs-demo=\"01-quick-start\" data-height=\"460\"></div>\r\n\r\n\r\n## Minimal\r\n\r\n```ts\r\nimport type { ColumnDef } from '@svgrid/grid'\r\n\r\ntype Person = { firstName: string; age: number; status: string }\r\n\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName', header: 'First name' },\r\n { field: 'age', header: 'Age' },\r\n { field: 'status', header: 'Status' },\r\n]\r\n```\r\n\r\n## Properties\r\n\r\n| Property | Type | Purpose |\r\n| --- | --- | --- |\r\n| `id` | `string` | Stable column id. Required when you use `fieldFn` and no `field`. |\r\n| `field` | `keyof TData & string` | Reads `row[key]`. |\r\n| `fieldFn` | `(row) => unknown` | Computes the value. |\r\n| `header` | `string` \\| `(ctx) => unknown` | String, or a function returning a `renderSnippet` / `renderComponent`. |\r\n| `cell` | `(ctx) => unknown` | Same shape as `header`, for body cells. |\r\n| `footer` | `string` \\| `(ctx) => unknown` | Footer cell. |\r\n| `editorType` | `'text' \\| 'number' \\| 'date' \\| 'datetime' \\| 'checkbox'` | Inline editor type. |\r\n| `format` | `CellFormatConfig` | Built-in `number`, `currency`, `percent`, `date`, `datetime` formatters. |\r\n| `formatter` | `(ctx) => string` | Custom formatter - runs after `field` / `fieldFn`. |\r\n| `columns` | `ColumnDef[]` | Children - turns this column into a column **group**. |\r\n| `width` | `number` | Initial width in pixels (overrides the grid's `columnWidth`). |\r\n\r\nSee [`packages/grid/src/core.ts`](../../../packages/grid/src/core.ts).\r\n\r\n## Accessor vs. fieldFn\r\n\r\n`field` is the common case. Use `fieldFn` when the value is\r\ncomputed or comes from a nested object:\r\n\r\n```ts\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName', header: 'First' },\r\n {\r\n id: 'fullName',\r\n header: 'Full name',\r\n fieldFn: (row) => `${row.firstName} ${row.lastName}`,\r\n },\r\n]\r\n```\r\n\r\nWhenever you use `fieldFn` you must supply an `id` - there is no string key\r\nto derive one from.\r\n\r\n## Format vs. formatter vs. cell\r\n\r\n| You want | Use |\r\n| -------- | --- |\r\n| Locale-aware number / currency / percent / date | `format` |\r\n| A custom string transformation | `formatter` |\r\n| Custom HTML (avatars, pills, progress, sparklines) | `cell` with `renderSnippet` |\r\n\r\n`format` is purely declarative and locale-aware - prefer it for anything\r\nnumeric or temporal:\r\n\r\n```ts\r\n{ field: 'salary', header: 'Salary',\r\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } } }\r\n\r\n{ field: 'joinedAt', header: 'Joined',\r\n format: { type: 'date', pattern: 'y-m-d' } }\r\n\r\n{ field: 'utilization', header: 'Utilization',\r\n format: { type: 'percent', valueIsPercentPoints: true } } // 42 -> 42%\r\n```\r\n\r\n`formatter` runs after the accessor; the result is what gets displayed\r\n(and what gets copied to the clipboard during cell selection).\r\n\r\n`cell` is the most powerful - see [Cell components](../cells/cell-components.md).\r\n\r\n## TypeScript\r\n\r\nPass the row type as the second generic; the column's `field` is then\r\nchecked against the row's keys:\r\n\r\n```ts\r\nconst columns: ColumnDef<{}, Person>[] = [\r\n { field: 'firstName' }, // āœ…\r\n // { field: 'first_name' } // āœ— compile error\r\n]\r\n```\r\n\r\nThe first generic is the **feature set** - derive it from `tableFeatures` so\r\nfeature-specific column properties light up:\r\n\r\n```ts\r\nconst features = tableFeatures({ rowSortingFeature, columnFilteringFeature })\r\ntype Features = typeof features\r\n\r\nconst columns: ColumnDef<Features, Person>[] = [/* … */]\r\n```\r\n\r\n## See also\r\n\r\n- [Updating definitions](./updating-definitions.md)\r\n- [Column state](./column-state.md)\r\n- [Custom header components](./custom-header-components.md)\r\n- Example: [`02-sort-filter-paginate.svelte`](../../../examples/src/demos/02-sort-filter-paginate.svelte)\r\n"
3159
3180
  },
3160
3181
  {
3161
3182
  "slug": "help/columns/column-groups",
3162
3183
  "path": "docs/help/columns/column-groups.md",
3163
3184
  "title": "Column groups",
3164
- "markdown": "# Column groups\n\nA column group is a `ColumnDef` whose `columns` array contains children.\nThe parent renders a spanning header above its children. The pivot demo\nbelow shows three levels of grouped headers in action - Year wraps\nQuarter wraps measure:\n\n<div data-docs-demo=\"52-pivot-table\" data-height=\"540\"></div>\n\n\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n { field: 'firstName', header: 'First name' },\n { field: 'lastName', header: 'Last name' },\n {\n id: 'compensation',\n header: 'Compensation',\n columns: [\n { field: 'salary', header: 'Salary',\n format: { type: 'currency', currency: 'USD' } },\n { field: 'bonus', header: 'Bonus',\n format: { type: 'currency', currency: 'USD' } },\n ],\n },\n]\n```\n\nRendered as:\n\n```\n| | Compensation |\n| First | Last | Salary | Bonus |\n```\n\n## How it works\n\n- The grid walks the column tree once, producing two header groups -\n the parent row and the leaf row.\n- Each parent header gets a `colSpan` equal to the count of leaf descendants\n it has.\n- The cell body only renders leaves.\n\n## Nested groups\n\nGroups can nest arbitrarily. The grid emits one header row per depth level:\n\n```ts\n{\n header: 'Q1',\n columns: [\n { header: 'Jan', field: 'jan' },\n { header: 'Feb', field: 'feb' },\n { header: 'Mar', field: 'mar' },\n ],\n},\n{\n header: 'Q2',\n columns: [/* … */],\n},\n```\n\n## Collapsible groups (`columnGroupShow`)\n\nGive a group a collapse toggle by tagging its child columns:\n\n- `columnGroupShow: 'open'` - the child shows **only while the group is expanded**,\n- `columnGroupShow: 'closed'` - shows **only while collapsed**,\n- omitted - always shown.\n\nSetting it on any direct child adds a caret to the group header. Use\n`openByDefault` on the group to start expanded (default is collapsed).\n\n```ts\n{\n id: 'q1', header: 'Q1', openByDefault: true,\n columns: [\n { field: 'q1Total', header: 'Total' }, // always visible\n { field: 'jan', header: 'Jan', columnGroupShow: 'open' }, // only when expanded\n { field: 'feb', header: 'Feb', columnGroupShow: 'open' },\n { field: 'mar', header: 'Mar', columnGroupShow: 'open' },\n ],\n}\n```\n\nCollapsing/expanding hides or shows the tagged leaves and the group header's\n`colSpan` recomputes so the multi-level header stays aligned.\n\n<div data-docs-demo=\"183-collapsible-column-groups\" data-height=\"480\"></div>\n\n## Group with a custom header\n\nThe same `header: (ctx) => renderSnippet(...)` pattern from\n[custom header components](./custom-header-components.md) works for group\nheaders. The `ctx.header.colSpan` value will be the rendered span.\n\n## Gotchas\n\n- A group needs an `id` (or a string `header`) - the grid uses it to give the\n parent header a stable DOM id.\n- Hidden columns (`api.setColumnVisible(id, false)`) shrink their group's\n `colSpan` automatically.\n- A group cannot be sorted or filtered - only its leaves can.\n\n## See also\n\n- [Column definitions](./column-definitions.md)\n- [Custom header components](./custom-header-components.md)\n"
3185
+ "markdown": "# Column groups\n\nA column group is a `ColumnDef` whose `columns` array contains children.\nThe parent renders a spanning header above its children. The pivot demo\nbelow shows three levels of grouped headers in action - Year wraps\nQuarter wraps measure:\n\n<div data-docs-demo=\"52-pivot-table\" data-height=\"540\"></div>\n\n\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n { field: 'firstName', header: 'First name' },\n { field: 'lastName', header: 'Last name' },\n {\n id: 'compensation',\n header: 'Compensation',\n columns: [\n { field: 'salary', header: 'Salary',\n format: { type: 'currency', currency: 'USD' } },\n { field: 'bonus', header: 'Bonus',\n format: { type: 'currency', currency: 'USD' } },\n ],\n },\n]\n```\n\nRendered as:\n\n```\n| | Compensation |\n| First | Last | Salary | Bonus |\n```\n\n## How it works\n\n- The grid walks the column tree once, producing two header groups -\n the parent row and the leaf row.\n- Each parent header gets a `colSpan` equal to the count of leaf descendants\n it has.\n- The cell body only renders leaves.\n\n## Nested groups\n\nGroups can nest arbitrarily. The grid emits one header row per depth level:\n\n```ts\n{\n header: 'Q1',\n columns: [\n { header: 'Jan', field: 'jan' },\n { header: 'Feb', field: 'feb' },\n { header: 'Mar', field: 'mar' },\n ],\n},\n{\n header: 'Q2',\n columns: [/* … */],\n},\n```\n\n## Collapsible groups (`columnGroupShow`)\n\nGive a group a collapse toggle by tagging its child columns:\n\n- `columnGroupShow: 'open'` - the child shows **only while the group is expanded**,\n- `columnGroupShow: 'closed'` - shows **only while collapsed**,\n- omitted - always shown.\n\nSetting it on any direct child adds a caret to the group header. Use\n`openByDefault` on the group to start expanded (default is collapsed).\n\n```ts\n{\n id: 'q1', header: 'Q1', openByDefault: true,\n columns: [\n { field: 'q1Total', header: 'Total' }, // always visible\n { field: 'jan', header: 'Jan', columnGroupShow: 'open' }, // only when expanded\n { field: 'feb', header: 'Feb', columnGroupShow: 'open' },\n { field: 'mar', header: 'Mar', columnGroupShow: 'open' },\n ],\n}\n```\n\nCollapsing/expanding hides or shows the tagged leaves and the group header's\n`colSpan` recomputes so the multi-level header stays aligned.\n\n<div data-docs-demo=\"183-collapsible-column-groups\" data-height=\"480\"></div>\n\n## Group with a custom header\n\nThe same `header: (ctx) => renderSnippet(...)` pattern from\n[custom header components](./custom-header-components.md) works for group\nheaders. The `ctx.header.colSpan` value will be the rendered span.\n\n## Gotchas\n\n- A group needs an `id` (or a string `header`) - the grid uses it to give the\n parent header a stable DOM id.\n- Hidden columns (`api.setColumnVisible(id, false)`) shrink their group's\n `colSpan` automatically.\n- A group cannot be sorted or filtered - only its leaves can.\n\n## See also\n\n- [Column definitions](./column-definitions.md)\n- [Custom header components](./custom-header-components.md)\n"
3165
3186
  },
3166
3187
  {
3167
3188
  "slug": "help/columns/column-headers",
@@ -3185,7 +3206,7 @@ export const docs = [
3185
3206
  "slug": "help/columns/column-pinning",
3186
3207
  "path": "docs/help/columns/column-pinning.md",
3187
3208
  "title": "Column pinning",
3188
- "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\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## See also\n\n- [Column state](./column-state.md)\n- [`SvGridApi` reference](../../reference/SvGridApi.md) - `setColumnPinning` / `getColumnPinning` signatures.\n"
3209
+ "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\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## See also\n\n- [Column state](./column-state.md)\n- [`SvGridApi` reference](../../reference/SvGridApi.md) - `setColumnPinning` / `getColumnPinning` signatures.\n"
3189
3210
  },
3190
3211
  {
3191
3212
  "slug": "help/columns/column-sizing",
@@ -3215,13 +3236,13 @@ export const docs = [
3215
3236
  "slug": "help/columns/tool-panel",
3216
3237
  "path": "docs/help/columns/tool-panel.md",
3217
3238
  "title": "Tool panel (Columns + Filters)",
3218
- "markdown": "# Tool panel (Columns + Filters)\n\nThe tool panel is the docked sidebar - standard in enterprise grids - for\nmanaging columns and filters without hunting through a right-click menu. Turn it\non with the `toolPanel` prop:\n\n<div data-docs-demo=\"146-tool-panel\" data-height=\"480\"></div>\n\n```svelte\n<SvGrid {data} {columns} {features} toolPanel />\n```\n\nA **Columns & Filters** button appears in a toolbar above the grid. Clicking it\nopens a panel docked on the right edge with two tabs. Pass `toolPanelDefaultOpen`\nto have it open on first render, and `toolPanelDefaultTab=\"filters\"` to start on\nthe Filters tab:\n\n```svelte\n<SvGrid {data} {columns} {features} toolPanel toolPanelDefaultOpen />\n```\n\n## Columns tab\n\nFor every column:\n\n- a **visibility** checkbox (show / hide the column),\n- **↑ / ↓** to reorder the column,\n- **āŠž** to group / ungroup by that column (when grouping is enabled).\n\n## Filters tab\n\nFor every filterable column, an inline filter control:\n\n- an **operator** select (the same operators the column menu offers - text\n columns get `contains` / `equals` / …, number and date columns get\n `greaterThan` / `between` / …; drive these with\n [`cellDataType`](../cells/cell-data-types.md)),\n- a **value** input matched to the column type, plus a second **To** input for\n `between`,\n- a **āœ•** to clear that column's filter.\n\nThe Filters tab writes the **same** filter state as the column menu and the\nfilter row, so all three surfaces stay in sync - filter from whichever is handy.\nIt works whenever `columnFilteringFeature` is enabled.\n\n## Notes\n\n- The panel lists **all** columns, including hidden ones, in the current\n display order, so you can bring a hidden column back.\n- Visibility, order, and grouping changes go through the same engine state as\n the column menu and the imperative API (`setColumnVisible`,\n `setColumnOrder`, `setGroupBy`) - so they round-trip with `getState()` /\n `setState()` and [named views](../state/named-views.md).\n- Grouping a column whose other columns declare an\n [`aggregate`](../grouping/aggregators.md) rolls those values up in the group\n header automatically.\n\nSee the live [Columns tool panel](https://sv-grid.com/demos/146-tool-panel)\ndemo.\n"
3239
+ "markdown": "# Tool panel (Columns + Filters)\n\nThe tool panel is the docked sidebar - standard in enterprise grids - for\nmanaging columns and filters without hunting through a right-click menu. Turn it\non with the `toolPanel` prop:\n\n<div data-docs-demo=\"146-tool-panel\" data-height=\"480\"></div>\n\n```svelte\n<SvGrid {data} {columns} {features} toolPanel />\n```\n\nA **Columns & Filters** button appears in a toolbar above the grid. Clicking it\nopens a panel docked on the right edge with two tabs. Pass `toolPanelDefaultOpen`\nto have it open on first render, and `toolPanelDefaultTab=\"filters\"` to start on\nthe Filters tab:\n\n```svelte\n<SvGrid {data} {columns} {features} toolPanel toolPanelDefaultOpen />\n```\n\n## Columns tab\n\nFor every column:\n\n- a **visibility** checkbox (show / hide the column),\n- **Ć¢ā€ ā€˜ / Ć¢ā€ ā€œ** to reorder the column,\n- **⊞** to group / ungroup by that column (when grouping is enabled).\n\n## Filters tab\n\nFor every filterable column, an inline filter control:\n\n- an **operator** select (the same operators the column menu offers - text\n columns get `contains` / `equals` / …, number and date columns get\n `greaterThan` / `between` / …; drive these with\n [`cellDataType`](../cells/cell-data-types.md)),\n- a **value** input matched to the column type, plus a second **To** input for\n `between`,\n- a **✕** to clear that column's filter.\n\nThe Filters tab writes the **same** filter state as the column menu and the\nfilter row, so all three surfaces stay in sync - filter from whichever is handy.\nIt works whenever `columnFilteringFeature` is enabled.\n\n## Notes\n\n- The panel lists **all** columns, including hidden ones, in the current\n display order, so you can bring a hidden column back.\n- Visibility, order, and grouping changes go through the same engine state as\n the column menu and the imperative API (`setColumnVisible`,\n `setColumnOrder`, `setGroupBy`) - so they round-trip with `getState()` /\n `setState()` and [named views](../state/named-views.md).\n- Grouping a column whose other columns declare an\n [`aggregate`](../grouping/aggregators.md) rolls those values up in the group\n header automatically.\n\nSee the live [Columns tool panel](https://svgrid.com/demos/146-tool-panel/)\ndemo.\n"
3219
3240
  },
3220
3241
  {
3221
3242
  "slug": "help/columns/updating-definitions",
3222
3243
  "path": "docs/help/columns/updating-definitions.md",
3223
3244
  "title": "Updating column definitions",
3224
- "markdown": "# Updating column definitions\n\nThere are two ways to change columns after the grid has mounted:\n\n## 1. Reassign the `columns` prop\n\n`<SvGrid columns={...}>` is reactive. Replace the array (or mutate a `$state`\narray) and the grid re-derives its internal columns.\n\n```svelte\n<script lang=\"ts\">\n let columns = $state<ColumnDef<{}, Person>[]>([\n { field: 'firstName', header: 'First name' },\n { field: 'age', header: 'Age' },\n ])\n\n function addCountry() {\n columns = [...columns, { field: 'country', header: 'Country' }]\n }\n</script>\n\n<button onclick={addCountry}>+ Country</button>\n<SvGrid {columns} data={rows} features={{}} />\n```\n\n## 2. Use the imperative API\n\nThe wrapper exposes mutators via `onApiReady`:\n\n```svelte\n<script lang=\"ts\">\n let api: SvGridApi<{}, Person> | null = $state(null)\n</script>\n\n<SvGrid {columns} data={rows} features={{}} onApiReady={(next) => (api = next)} />\n\n<button onclick={() => api?.addColumn({ field: 'country', header: 'Country' })}>\n + Country\n</button>\n```\n\nAvailable column mutators on `SvGridApi`:\n\n| Method | What it does |\n| ------ | ------------ |\n| `addColumn(col, position?)` | Insert one column. `position` is `'left' \\| 'right' \\| number` (default `'right'`). |\n| `addColumns(cols, position?)` | Insert many. |\n| `removeColumn(id)` | Remove by column id (or field when no `id`). |\n| `setColumnVisible(id, visible)` | Show / hide. |\n| `isColumnVisible(id)` | Read visibility. |\n\nThe imperative path is the right choice when the column-change initiator is\n**outside** the parent that owns the `columns` array - e.g. a toolbar\ncomponent that doesn't know about the data source.\n\n## What is preserved when columns change\n\nWhen you add or remove a column:\n\n- Sort state survives if the sorted column is still present.\n- Filter state for the removed column is discarded.\n- Active-cell focus is clamped into bounds.\n- Column widths set by the user via resize handles are preserved by column id.\n- Pinning is preserved by column id.\n\nWhen you **reorder** columns by reassigning the array, the grid renders them\nin the new order; pinned-left and pinned-right groups retain their order\nrelative to themselves.\n\n## Gotchas\n\n- Anything that captures `ctx.column.columnDef` inside a `cell` callback will\n see the **new** column def after a swap. Don't cache it.\n- If you reassign the entire `columns` array on every render, you'll pay the\n cost of re-deriving headers each time. Memoise it (build once with\n `$state.raw` or a one-time IIFE) for hot-loop components.\n\n## See also\n\n- [Column state](./column-state.md)\n- [Column moving](./column-moving.md)\n"
3245
+ "markdown": "# Updating column definitions\n\nThere are two ways to change columns after the grid has mounted:\n\n## 1. Reassign the `columns` prop\n\n`<SvGrid columns={...}>` is reactive. Replace the array (or mutate a `$state`\narray) and the grid re-derives its internal columns.\n\n```svelte\n<script lang=\"ts\">\n let columns = $state<ColumnDef<{}, Person>[]>([\n { field: 'firstName', header: 'First name' },\n { field: 'age', header: 'Age' },\n ])\n\n function addCountry() {\n columns = [...columns, { field: 'country', header: 'Country' }]\n }\n</script>\n\n<button onclick={addCountry}>+ Country</button>\n<SvGrid {columns} data={rows} features={{}} />\n```\n\n## 2. Use the imperative API\n\nThe wrapper exposes mutators via `onApiReady`:\n\n```svelte\n<script lang=\"ts\">\n let api: SvGridApi<{}, Person> | null = $state(null)\n</script>\n\n<SvGrid {columns} data={rows} features={{}} onApiReady={(next) => (api = next)} />\n\n<button onclick={() => api?.addColumn({ field: 'country', header: 'Country' })}>\n + Country\n</button>\n```\n\nAvailable column mutators on `SvGridApi`:\n\n| Method | What it does |\n| ------ | ------------ |\n| `addColumn(col, position?)` | Insert one column. `position` is `'left' \\| 'right' \\| number` (default `'right'`). |\n| `addColumns(cols, position?)` | Insert many. |\n| `removeColumn(id)` | Remove by column id (or field when no `id`). |\n| `setColumnVisible(id, visible)` | Show / hide. |\n| `isColumnVisible(id)` | Read visibility. |\n\nThe imperative path is the right choice when the column-change initiator is\n**outside** the parent that owns the `columns` array - e.g. a toolbar\ncomponent that doesn't know about the data source.\n\n## What is preserved when columns change\n\nWhen you add or remove a column:\n\n- Sort state survives if the sorted column is still present.\n- Filter state for the removed column is discarded.\n- Active-cell focus is clamped into bounds.\n- Column widths set by the user via resize handles are preserved by column id.\n- Pinning is preserved by column id.\n\nWhen you **reorder** columns by reassigning the array, the grid renders them\nin the new order; pinned-left and pinned-right groups retain their order\nrelative to themselves.\n\n## Gotchas\n\n- Anything that captures `ctx.column.columnDef` inside a `cell` callback will\n see the **new** column def after a swap. Don't cache it.\n- If you reassign the entire `columns` array on every render, you'll pay the\n cost of re-deriving headers each time. Memoise it (build once with\n `$state.raw` or a one-time IIFE) for hot-loop components.\n\n## See also\n\n- [Column state](./column-state.md)\n- [Column moving](./column-moving.md)\n"
3225
3246
  },
3226
3247
  {
3227
3248
  "slug": "help/comparison",
@@ -3251,7 +3272,7 @@ export const docs = [
3251
3272
  "slug": "help/editing/overview",
3252
3273
  "path": "docs/help/editing/overview.md",
3253
3274
  "title": "Editing - overview",
3254
- "markdown": "# Editing - overview\n\nInline editing is a single prop on `<SvGrid>`. Try it - double-click\nany cell, type to replace, hit `Enter` to commit. Tab moves to the\nnext editable cell:\n\n![An edit starts, opens the editor component, parses and validates the value, then either saves the commit or cancels and discards.](/docs-media/grid-editing-lifecycle.svg)\n\n<div data-docs-demo=\"05-inline-editing\" data-height=\"440\"></div>\n\n\n\n```svelte\n<SvGrid {data} {columns} features={features} enableInlineEditing={true} />\n```\n\nTo make a specific column editable, give it an `editorType`:\n\n```ts\nconst columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n { field: 'active', header: 'Active', editorType: 'checkbox' },\n]\n```\n\nA column with no `editorType` is **read-only** even when\n`enableInlineEditing={true}`.\n\n## How a user edits\n\n| Action | Keys | Outcome |\n| ------ | ---- | ------- |\n| Enter edit mode | `Enter` / `F2` / double-click | Editor opens on the active cell |\n| Commit | `Enter` / `Tab` | Saves the new value |\n| Cancel | `Esc` | Discards |\n| Move to next field while editing | `Tab` / `Shift+Tab` | Commits and re-enters edit on the neighbour |\n\n## What gets saved\n\nWhen the user commits, the grid:\n\n1. Parses the editor's string value through `parseEditorValue` for the\n column's `editorType`.\n2. Writes the parsed value into the grid's internal data copy.\n3. Fires `onCellValueChange` with `{ rowIndex, columnId, oldValue, newValue, row }`.\n4. Fires a re-render.\n\nTo round-trip edits to your source, attach a callback:\n\n```svelte\n<SvGrid\n {data} {columns} features={features}\n enableInlineEditing={true}\n onCellValueChange={(e) => savePersonField(e.row.id, e.columnId, e.newValue)}\n/>\n```\n\nSee [Saving values](./saving-values.md) for the full patterns\n(per-edit, batch-from-snapshot, cascade recompute).\n\n## See also\n\n- [Start / stop editing](./start-stop-editing.md)\n- [Parsing values](./parsing-values.md)\n- [Saving values](./saving-values.md)\n- [Provided editors](./provided-editors.md)\n- [Validation](./validation.md)\n\n## Frequently asked questions\n\n### How do I enable inline editing in SvGrid?\n\nSet the inline-editing prop on `<SvGrid>` and give editable columns an\n`editorType`. Then double-click a cell (or press F2), type to replace, and press\nEnter to commit; Tab moves to the next editable cell.\n\n### What cell editors does SvGrid provide?\n\nBuilt-in editors for text, number, checkbox, date, select, rich-select, and\ntextarea, chosen per column via `editorType`. You can also supply a custom\neditor through the `cellEditor` slot.\n\n### Does SvGrid mutate my data array when editing?\n\nNo. Commits are written to the grid's internal working copy, not the array you\npassed in, so cancel/undo is possible. Subscribe to `onCellValueChange` to\npersist edits to your own state or backend.\n"
3275
+ "markdown": "# Editing - overview\n\nInline editing is a single prop on `<SvGrid>`. Try it - double-click\nany cell, type to replace, hit `Enter` to commit. Tab moves to the\nnext editable cell:\n\n![An edit starts, opens the editor component, parses and validates the value, then either saves the commit or cancels and discards.](/docs-media/grid-editing-lifecycle.svg)\n\n<div data-docs-demo=\"05-inline-editing\" data-height=\"440\"></div>\n\n\n\n```svelte\n<SvGrid {data} {columns} features={features} enableInlineEditing={true} />\n```\n\nTo make a specific column editable, give it an `editorType`:\n\n```ts\nconst columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n { field: 'active', header: 'Active', editorType: 'checkbox' },\n]\n```\n\nA column with no `editorType` is **read-only** even when\n`enableInlineEditing={true}`.\n\n## How a user edits\n\n| Action | Keys | Outcome |\n| ------ | ---- | ------- |\n| Enter edit mode | `Enter` / `F2` / double-click | Editor opens on the active cell |\n| Commit | `Enter` / `Tab` | Saves the new value |\n| Cancel | `Esc` | Discards |\n| Move to next field while editing | `Tab` / `Shift+Tab` | Commits and re-enters edit on the neighbour |\n\n## What gets saved\n\nWhen the user commits, the grid:\n\n1. Parses the editor's string value through `parseEditorValue` for the\n column's `editorType`.\n2. Writes the parsed value into the grid's internal data copy.\n3. Fires `onCellValueChange` with `{ rowIndex, columnId, oldValue, newValue, row }`.\n4. Fires a re-render.\n\nTo round-trip edits to your source, attach a callback:\n\n```svelte\n<SvGrid\n {data} {columns} features={features}\n enableInlineEditing={true}\n onCellValueChange={(e) => savePersonField(e.row.id, e.columnId, e.newValue)}\n/>\n```\n\nSee [Saving values](./saving-values.md) for the full patterns\n(per-edit, batch-from-snapshot, cascade recompute).\n\n## See also\n\n- [Start / stop editing](./start-stop-editing.md)\n- [Parsing values](./parsing-values.md)\n- [Saving values](./saving-values.md)\n- [Provided editors](./provided-editors.md)\n- [Validation](./validation.md)\n\n## Frequently asked questions\n\n### How do I enable inline editing in SvGrid?\n\nSet the inline-editing prop on `<SvGrid>` and give editable columns an\n`editorType`. Then double-click a cell (or press F2), type to replace, and press\nEnter to commit; Tab moves to the next editable cell.\n\n### What cell editors does SvGrid provide?\n\nBuilt-in editors for text, number, checkbox, date, select, rich-select, and\ntextarea, chosen per column via `editorType`. You can also supply a custom\neditor through the `cellEditor` slot.\n\n### Does SvGrid mutate my data array when editing?\n\nNo. Commits are written to the grid's internal working copy, not the array you\npassed in, so cancel/undo is possible. Subscribe to `onCellValueChange` to\npersist edits to your own state or backend.\n"
3255
3276
  },
3256
3277
  {
3257
3278
  "slug": "help/editing/parsing-values",
@@ -3263,7 +3284,7 @@ export const docs = [
3263
3284
  "slug": "help/editing/provided-editors",
3264
3285
  "path": "docs/help/editing/provided-editors.md",
3265
3286
  "title": "Provided cell editors",
3266
- "markdown": "# Provided cell editors\n\nThe grid ships nine built-in editors, selected via `editorType` on the\ncolumn definition. Each editor renders inside the cell while editing\nand falls back to the default text render (or your `cell` snippet) when\nthe cell is read-only.\n<div data-docs-demo=\"26-list-chips-editors\" data-height=\"540\"></div>\n\n| `editorType` | Renders | Stored value |\n|---------------|--------------------------------------|-------------------------|\n| `'text'` | `<input type=\"text\">` | `string` |\n| `'number'` | `<input type=\"number\">` | `number \\| null` |\n| `'date'` | `<input type=\"date\">` | ISO `YYYY-MM-DD` string |\n| `'datetime'` | `<input type=\"datetime-local\">` | ISO 8601 string |\n| `'checkbox'` | Themed checkbox button | `boolean` |\n| `'list'` | Custom dropdown (single / multi) | scalar or array |\n| `'chips'` | Removable tag picker | array of values |\n| `'color'` | Native OS color picker | `#rrggbb` string |\n| `'rating'` | 5-star clickable widget | `number` 0-5 |\n\n## Text editor - `editorType: 'text'`\n\n`<input type=\"text\">`. Accepts any string. On commit the raw value is\nstored.\n\n```ts\n{ field: 'firstName', header: 'First name', editorType: 'text' }\n```\n\n## Number editor - `editorType: 'number'`\n\n`<input type=\"number\">` with browser-native increment buttons.\nNon-numeric input is rejected at commit (`parseEditorValue` returns\n`null` and the cell stays at its previous value).\n\n```ts\n{ field: 'age', header: 'Age', editorType: 'number' }\n\n// often paired with a display format:\n{\n field: 'salary', header: 'Salary',\n editorType: 'number',\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\n}\n```\n\n## Date editor - `editorType: 'date'`\n\n`<input type=\"date\">`. The value is stored as ISO `YYYY-MM-DD`.\n\n```ts\n{\n field: 'joinedAt', header: 'Joined',\n editorType: 'date',\n format: { type: 'date', pattern: 'y-m-d' },\n}\n```\n\n## Datetime editor - `editorType: 'datetime'`\n\n`<input type=\"datetime-local\">`. The value is stored as ISO 8601 with a Z\nsuffix.\n\n```ts\n{\n field: 'updatedAt', header: 'Updated',\n editorType: 'datetime',\n format: { type: 'datetime', pattern: 'medium' },\n}\n```\n\n## Checkbox editor - `editorType: 'checkbox'`\n\nA themed button that toggles between `true` and `false`. Renders centred\nin the cell.\n\n```ts\n{ field: 'active', header: 'Active', editorType: 'checkbox' }\n```\n\n## List editor - `editorType: 'list'`\n\nSingle-select dropdown. Accepts an array of strings/numbers or\n`{ value, label, color }` objects. Set `editorMultiple: true` for a\nmulti-select.\n\n```ts\n{\n field: 'priority', header: 'Priority',\n editorType: 'list',\n editorOptions: ['low', 'med', 'high', 'urgent'],\n}\n\n// labelled options:\n{\n field: 'status', header: 'Status',\n editorType: 'list',\n editorOptions: [\n { value: 'open', label: 'Open' },\n { value: 'in_progress', label: 'In progress' },\n { value: 'done', label: 'Done' },\n ],\n}\n```\n\n## Chips editor - `editorType: 'chips'`\n\nRemovable-token picker for multi-select. The value is always an array.\n\n```ts\n{\n field: 'tags', header: 'Tags',\n editorType: 'chips',\n editorOptions: ['frontend', 'backend', 'design', 'infra', 'docs'],\n}\n```\n\n## Color editor - `editorType: 'color'`\n\nNative HTML color picker. The cell stores a `#rrggbb` string. Clicking\nthe cell opens the OS color overlay; the value commits as soon as the\noverlay closes (no need to blur the cell first).\n\n```ts\n{ field: 'brandColor', header: 'Brand', editorType: 'color' }\n```\n\n### Complete example\n\nA minimal grid where each row has its own swatch. The custom `cell`\nsnippet shows the colour next to the hex value while not editing; the\neditor takes over on double-click.\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n renderSnippet,\n type ColumnDef,\n } from '@svgrid/grid'\n\n type Brand = { id: string; name: string; color: string }\n\n let rows = $state<Brand[]>([\n { id: 'b1', name: 'Acme', color: '#6366f1' },\n { id: 'b2', name: 'Helios', color: '#10b981' },\n { id: 'b3', name: 'Crescent', color: '#f59e0b' },\n ])\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Brand>[] = [\n { field: 'name', header: 'Brand', editorType: 'text', width: 200 },\n {\n field: 'color', header: 'Color',\n editorType: 'color',\n width: 180,\n cell: (ctx) => renderSnippet(Swatch, { row: ctx.row.original }),\n },\n ]\n</script>\n\n{#snippet Swatch(props: { row: Brand })}\n <span style=\"display: inline-flex; align-items: center; gap: 8px;\">\n <span style=\"width: 18px; height: 18px; border-radius: 4px; background: {props.row.color}; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.18);\"></span>\n <code style=\"font-family: ui-monospace, Menlo, monospace; font-size: 11.5px;\">{props.row.color}</code>\n </span>\n{/snippet}\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n enableInlineEditing={true}\n enableCellSelection={true}\n containerHeight=\"100%\"\n/>\n```\n\n### Theme tokens\n\nThe color editor inherits the grid's editor chrome. There are no\nextra tokens; the swatch fills the cell so the clickable area is the\nwhole cell, not the OS default 25 Ɨ 13 swatch.\n\n## Rating editor - `editorType: 'rating'`\n\nA row of 5 clickable stars plus a clear button. The cell stores an\ninteger 0-5. Clicking a star commits immediately - no blur required.\n\n```ts\n{ field: 'csat', header: 'CSAT', editorType: 'rating' }\n```\n\n### Complete example\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid,\n tableFeatures,\n rowSortingFeature,\n renderSnippet,\n type ColumnDef,\n } from '@svgrid/grid'\n\n type Review = { id: string; product: string; rating: number }\n\n let rows = $state<Review[]>([\n { id: 'r1', product: 'Aurora keyboard', rating: 5 },\n { id: 'r2', product: 'Helios monitor', rating: 4 },\n { id: 'r3', product: 'Crescent mouse', rating: 2 },\n ])\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Review>[] = [\n { field: 'product', header: 'Product', editorType: 'text', width: 220 },\n {\n field: 'rating', header: 'Rating',\n editorType: 'rating',\n width: 160,\n cell: (ctx) => renderSnippet(Stars, { row: ctx.row.original }),\n },\n ]\n</script>\n\n{#snippet Stars(props: { row: Review })}\n <span aria-label={`${props.row.rating} of 5`}>\n {#each [1, 2, 3, 4, 5] as n (n)}\n <span style=\"color: {props.row.rating >= n ? '#f59e0b' : '#cbd5e1'}; font-size: 16px;\">ā˜…</span>\n {/each}\n </span>\n{/snippet}\n\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n enableInlineEditing={true}\n enableCellSelection={true}\n containerHeight=\"100%\"\n/>\n```\n\n### Theme tokens\n\nThe rating editor reads three optional CSS custom properties so the\nstar colors can match your design system:\n\n| Token | Default | Used for |\n|-------------------------|-------------|----------------------|\n| `--sg-rating-empty` | `#cbd5e1` | Unselected stars |\n| `--sg-rating-on` | `#f59e0b` | Selected stars |\n| `--sg-rating-hover` | `#fbbf24` | Hover preview |\n\nOverride per theme:\n\n```css\n[data-theme='dark'] {\n --sg-rating-empty: #475569;\n --sg-rating-on: #fbbf24;\n}\n```\n\n## Disabling an editor on a per-cell basis\n\n`editable` accepts a callback. Return `false` to lock the cell - the\neditor never opens, even if you double-click. This composes with all\nthe editor types above.\n\n```ts\n{\n field: 'salary', header: 'Salary',\n editorType: 'number',\n // Only admins can edit salaries.\n editable: (ctx) => currentUser.role === 'admin',\n}\n```\n\nFor declarative `when`-style rules across many columns, see the\n[Conditional form schema](../recipes.md#conditional-form-schema)\nrecipe.\n\n## See also\n\n- [Cell data types](../cells/cell-data-types.md)\n- [Parsing values](./parsing-values.md)\n- [Validation](./validation.md)\n- [Demo 80 - Cell types showcase](../../examples/src/demos/80-cell-types-showcase.svelte) - every editor in one enterprise grid\n- [Demo 66 - Custom cell editors](../../examples/src/demos/66-custom-cell-editors.svelte) - feature-health board using color, rating, and mood\n"
3287
+ "markdown": "# Provided cell editors\r\n\r\nThe grid ships nine built-in editors, selected via `editorType` on the\r\ncolumn definition. Each editor renders inside the cell while editing\r\nand falls back to the default text render (or your `cell` snippet) when\r\nthe cell is read-only.\r\n<div data-docs-demo=\"26-list-chips-editors\" data-height=\"540\"></div>\r\n\r\n| `editorType` | Renders | Stored value |\r\n|---------------|--------------------------------------|-------------------------|\r\n| `'text'` | `<input type=\"text\">` | `string` |\r\n| `'number'` | `<input type=\"number\">` | `number \\| null` |\r\n| `'date'` | `<input type=\"date\">` | ISO `YYYY-MM-DD` string |\r\n| `'datetime'` | `<input type=\"datetime-local\">` | ISO 8601 string |\r\n| `'checkbox'` | Themed checkbox button | `boolean` |\r\n| `'list'` | Custom dropdown (single / multi) | scalar or array |\r\n| `'chips'` | Removable tag picker | array of values |\r\n| `'color'` | Native OS color picker | `#rrggbb` string |\r\n| `'rating'` | 5-star clickable widget | `number` 0-5 |\r\n\r\n## Text editor - `editorType: 'text'`\r\n\r\n`<input type=\"text\">`. Accepts any string. On commit the raw value is\r\nstored.\r\n\r\n```ts\r\n{ field: 'firstName', header: 'First name', editorType: 'text' }\r\n```\r\n\r\n## Number editor - `editorType: 'number'`\r\n\r\n`<input type=\"number\">` with browser-native increment buttons.\r\nNon-numeric input is rejected at commit (`parseEditorValue` returns\r\n`null` and the cell stays at its previous value).\r\n\r\n```ts\r\n{ field: 'age', header: 'Age', editorType: 'number' }\r\n\r\n// often paired with a display format:\r\n{\r\n field: 'salary', header: 'Salary',\r\n editorType: 'number',\r\n format: { type: 'currency', currency: 'USD', options: { maximumFractionDigits: 0 } },\r\n}\r\n```\r\n\r\n## Date editor - `editorType: 'date'`\r\n\r\n`<input type=\"date\">`. The value is stored as ISO `YYYY-MM-DD`.\r\n\r\n```ts\r\n{\r\n field: 'joinedAt', header: 'Joined',\r\n editorType: 'date',\r\n format: { type: 'date', pattern: 'y-m-d' },\r\n}\r\n```\r\n\r\n## Datetime editor - `editorType: 'datetime'`\r\n\r\n`<input type=\"datetime-local\">`. The value is stored as ISO 8601 with a Z\r\nsuffix.\r\n\r\n```ts\r\n{\r\n field: 'updatedAt', header: 'Updated',\r\n editorType: 'datetime',\r\n format: { type: 'datetime', pattern: 'medium' },\r\n}\r\n```\r\n\r\n## Checkbox editor - `editorType: 'checkbox'`\r\n\r\nA themed button that toggles between `true` and `false`. Renders centred\r\nin the cell.\r\n\r\n```ts\r\n{ field: 'active', header: 'Active', editorType: 'checkbox' }\r\n```\r\n\r\n## List editor - `editorType: 'list'`\r\n\r\nSingle-select dropdown. Accepts an array of strings/numbers or\r\n`{ value, label, color }` objects. Set `editorMultiple: true` for a\r\nmulti-select.\r\n\r\n```ts\r\n{\r\n field: 'priority', header: 'Priority',\r\n editorType: 'list',\r\n editorOptions: ['low', 'med', 'high', 'urgent'],\r\n}\r\n\r\n// labelled options:\r\n{\r\n field: 'status', header: 'Status',\r\n editorType: 'list',\r\n editorOptions: [\r\n { value: 'open', label: 'Open' },\r\n { value: 'in_progress', label: 'In progress' },\r\n { value: 'done', label: 'Done' },\r\n ],\r\n}\r\n```\r\n\r\n## Chips editor - `editorType: 'chips'`\r\n\r\nRemovable-token picker for multi-select. The value is always an array.\r\n\r\n```ts\r\n{\r\n field: 'tags', header: 'Tags',\r\n editorType: 'chips',\r\n editorOptions: ['frontend', 'backend', 'design', 'infra', 'docs'],\r\n}\r\n```\r\n\r\n## Async option lists\r\n\r\nAnywhere `editorOptions` is accepted it may also return a **Promise**, for\r\nlists that live on the server. Both the static and the per-row form support it:\r\n\r\n```ts\r\n// One request for the whole column.\r\n{ field: 'assignee', editorType: 'rich-select',\r\n editorOptions: fetch('/api/users').then((r) => r.json()) }\r\n\r\n// Per row - a cascade, where the list depends on another cell.\r\n{ field: 'city', editorType: 'select',\r\n editorOptions: (row) => fetch(`/api/cities?country=${row.country}`).then((r) => r.json()) }\r\n```\r\n\r\nWhile the request is in flight the dropdown shows **Loading…** rather than\r\n\"No options\", which would read as \"nothing to pick\". The cell keeps rendering\r\nits raw value.\r\n\r\nResults are cached so reopening an editor never refetches. A static source is\r\ncached per column; a cascade is cached per row **and per that row's data**, so\r\nediting the cell it depends on supersedes the entry and the next open refetches\r\n- change a row's Country and its City list reloads on its own.\r\n\r\nWhen the list changes server-side rather than in the row, invalidate explicitly:\r\n\r\n```ts\r\napi.refreshEditorOptions('city') // one column\r\napi.refreshEditorOptions() // everything\r\n```\r\n\r\nA rejected request settles the editor on an empty list, so a failed lookup\r\nnever leaves it spinning.\r\n\r\n<div data-docs-demo=\"428-async-editor-options\" data-height=\"420\"></div>\r\n\r\n## Color editor - `editorType: 'color'`\r\n\r\nNative HTML color picker. The cell stores a `#rrggbb` string. Clicking\r\nthe cell opens the OS color overlay; the value commits as soon as the\r\noverlay closes (no need to blur the cell first).\r\n\r\n```ts\r\n{ field: 'brandColor', header: 'Brand', editorType: 'color' }\r\n```\r\n\r\n### Complete example\r\n\r\nA minimal grid where each row has its own swatch. The custom `cell`\r\nsnippet shows the colour next to the hex value while not editing; the\r\neditor takes over on double-click.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n renderSnippet,\r\n type ColumnDef,\r\n } from '@svgrid/grid'\r\n\r\n type Brand = { id: string; name: string; color: string }\r\n\r\n let rows = $state<Brand[]>([\r\n { id: 'b1', name: 'Acme', color: '#6366f1' },\r\n { id: 'b2', name: 'Helios', color: '#10b981' },\r\n { id: 'b3', name: 'Crescent', color: '#f59e0b' },\r\n ])\r\n\r\n const features = tableFeatures({ rowSortingFeature })\r\n\r\n const columns: ColumnDef<typeof features, Brand>[] = [\r\n { field: 'name', header: 'Brand', editorType: 'text', width: 200 },\r\n {\r\n field: 'color', header: 'Color',\r\n editorType: 'color',\r\n width: 180,\r\n cell: (ctx) => renderSnippet(Swatch, { row: ctx.row.original }),\r\n },\r\n ]\r\n</script>\r\n\r\n{#snippet Swatch(props: { row: Brand })}\r\n <span style=\"display: inline-flex; align-items: center; gap: 8px;\">\r\n <span style=\"width: 18px; height: 18px; border-radius: 4px; background: {props.row.color}; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.18);\"></span>\r\n <code style=\"font-family: ui-monospace, Menlo, monospace; font-size: 11.5px;\">{props.row.color}</code>\r\n </span>\r\n{/snippet}\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n enableInlineEditing={true}\r\n enableCellSelection={true}\r\n containerHeight=\"100%\"\r\n/>\r\n```\r\n\r\n### Theme tokens\r\n\r\nThe color editor inherits the grid's editor chrome. There are no\r\nextra tokens; the swatch fills the cell so the clickable area is the\r\nwhole cell, not the OS default 25 Ɨ 13 swatch.\r\n\r\n## Rating editor - `editorType: 'rating'`\r\n\r\nA row of 5 clickable stars plus a clear button. The cell stores an\r\ninteger 0-5. Clicking a star commits immediately - no blur required.\r\n\r\n```ts\r\n{ field: 'csat', header: 'CSAT', editorType: 'rating' }\r\n```\r\n\r\n### Complete example\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n renderSnippet,\r\n type ColumnDef,\r\n } from '@svgrid/grid'\r\n\r\n type Review = { id: string; product: string; rating: number }\r\n\r\n let rows = $state<Review[]>([\r\n { id: 'r1', product: 'Aurora keyboard', rating: 5 },\r\n { id: 'r2', product: 'Helios monitor', rating: 4 },\r\n { id: 'r3', product: 'Crescent mouse', rating: 2 },\r\n ])\r\n\r\n const features = tableFeatures({ rowSortingFeature })\r\n\r\n const columns: ColumnDef<typeof features, Review>[] = [\r\n { field: 'product', header: 'Product', editorType: 'text', width: 220 },\r\n {\r\n field: 'rating', header: 'Rating',\r\n editorType: 'rating',\r\n width: 160,\r\n cell: (ctx) => renderSnippet(Stars, { row: ctx.row.original }),\r\n },\r\n ]\r\n</script>\r\n\r\n{#snippet Stars(props: { row: Review })}\r\n <span aria-label={`${props.row.rating} of 5`}>\r\n {#each [1, 2, 3, 4, 5] as n (n)}\r\n <span style=\"color: {props.row.rating >= n ? '#f59e0b' : '#cbd5e1'}; font-size: 16px;\">ā˜…</span>\r\n {/each}\r\n </span>\r\n{/snippet}\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n enableInlineEditing={true}\r\n enableCellSelection={true}\r\n containerHeight=\"100%\"\r\n/>\r\n```\r\n\r\n### Theme tokens\r\n\r\nThe rating editor reads three optional CSS custom properties so the\r\nstar colors can match your design system:\r\n\r\n| Token | Default | Used for |\r\n|-------------------------|-------------|----------------------|\r\n| `--sg-rating-empty` | `#cbd5e1` | Unselected stars |\r\n| `--sg-rating-on` | `#f59e0b` | Selected stars |\r\n| `--sg-rating-hover` | `#fbbf24` | Hover preview |\r\n\r\nOverride per theme:\r\n\r\n```css\r\n[data-theme='dark'] {\r\n --sg-rating-empty: #475569;\r\n --sg-rating-on: #fbbf24;\r\n}\r\n```\r\n\r\n## Disabling an editor on a per-cell basis\r\n\r\n`editable` accepts a callback. Return `false` to lock the cell - the\r\neditor never opens, even if you double-click. This composes with all\r\nthe editor types above.\r\n\r\n```ts\r\n{\r\n field: 'salary', header: 'Salary',\r\n editorType: 'number',\r\n // Only admins can edit salaries.\r\n editable: (ctx) => currentUser.role === 'admin',\r\n}\r\n```\r\n\r\nFor declarative `when`-style rules across many columns, see the\r\n[Conditional form schema](../recipes.md#conditional-form-schema)\r\nrecipe.\r\n\r\n## See also\r\n\r\n- [Cell data types](../cells/cell-data-types.md)\r\n- [Parsing values](./parsing-values.md)\r\n- [Validation](./validation.md)\r\n- [Demo 80 - Cell types showcase](../../examples/src/demos/80-cell-types-showcase.svelte) - every editor in one enterprise grid\r\n- [Demo 66 - Custom cell editors](../../examples/src/demos/66-custom-cell-editors.svelte) - feature-health board using color, rating, and mood\r\n"
3267
3288
  },
3268
3289
  {
3269
3290
  "slug": "help/editing/saving-values",
@@ -3287,7 +3308,7 @@ export const docs = [
3287
3308
  "slug": "help/editing/validation",
3288
3309
  "path": "docs/help/editing/validation.md",
3289
3310
  "title": "Validation",
3290
- "markdown": "# Validation\n\nThere is no `validate(value)` callback on `ColumnDef` today. Validation\nhappens by intercepting committed edits and either accepting or reverting\nthem.\n\nLive demo - per-column rules with rollback + a recent-rejections panel:\n\n<div data-docs-demo=\"24-validation\" data-height=\"500\"></div>\n\n## Built-in soft validation\n\n`parseEditorValue` already does light validation:\n\n- `number`: rejects non-finite results → returns `null`\n- `date` / `datetime`: rejects unparseable strings → returns `null`\n\nThe grid writes `null` into the cell when this happens. That is \"soft\"\nvalidation - the user sees the cell go blank rather than seeing their\ninput rejected with an explanation.\n\n## Hard validation (reject + revert)\n\nTo bounce the user back to the previous value with an explanation,\nmaintain your own snapshot and revert after the commit:\n\n```svelte\n<script lang=\"ts\">\n let api: SvGridApi<typeof features, Person> | null = $state(null)\n let initial = $state<Person[]>([])\n let error = $state<{ row: number; col: string; msg: string } | null>(null)\n\n function validateRow(row: Person): string | null {\n if (row.age < 0 || row.age > 130) return 'Age must be between 0 and 130.'\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(row.email)) return 'Invalid email.'\n return null\n }\n\n $effect(() => {\n if (!api) return\n const snap = api.getData()\n for (let i = 0; i < snap.length; i++) {\n const msg = validateRow(snap[i]!)\n if (msg) {\n // revert by writing back the original\n const original = initial[i]\n if (original) {\n for (const key of Object.keys(original) as Array<keyof Person>) {\n if ((snap[i] as any)[key] !== (original as any)[key]) {\n api!.setCellValue(i, key as string, (original as any)[key])\n }\n }\n }\n error = { row: i, col: '*', msg }\n return\n }\n }\n error = null\n initial = snap.map((r) => ({ ...r }))\n })\n</script>\n\n{#if error}\n <p class=\"text-rose-600\">Row {error.row + 1}: {error.msg}</p>\n{/if}\n\n<SvGrid {data} {columns} features={features} enableInlineEditing\n onApiReady={(next) => (api = next)} />\n```\n\nThis polling-based validator works but has obvious limits:\n\n- The validator runs on every reactive tick, not strictly on commit.\n- The user briefly sees the invalid value before it reverts.\n\nA per-column `validate(value, row, column)` returning `string | true` is\non the [gap list](../missing-features.md).\n\n## Inline error UI\n\nRender an asterisk / red border via a custom cell renderer that reads\nyour validation state map. See [Highlighting changes](../cells/highlighting-changes.md)\nfor the same pattern with a \"dirty\" indicator - substitute \"invalid\" for\n\"dirty\".\n\n## See also\n\n- [Parsing values](./parsing-values.md)\n- [Saving values](./saving-values.md)\n- [demos/05-inline-editing.svelte](../../../examples/src/demos/05-inline-editing.svelte)\n"
3311
+ "markdown": "# Validation\n\nThere is no `validate(value)` callback on `ColumnDef` today. Validation\nhappens by intercepting committed edits and either accepting or reverting\nthem.\n\nLive demo - per-column rules with rollback + a recent-rejections panel:\n\n<div data-docs-demo=\"24-validation\" data-height=\"500\"></div>\n\n## Built-in soft validation\n\n`parseEditorValue` already does light validation:\n\n- `number`: rejects non-finite results → returns `null`\n- `date` / `datetime`: rejects unparseable strings → returns `null`\n\nThe grid writes `null` into the cell when this happens. That is \"soft\"\nvalidation - the user sees the cell go blank rather than seeing their\ninput rejected with an explanation.\n\n## Hard validation (reject + revert)\n\nTo bounce the user back to the previous value with an explanation,\nmaintain your own snapshot and revert after the commit:\n\n```svelte\n<script lang=\"ts\">\n let api: SvGridApi<typeof features, Person> | null = $state(null)\n let initial = $state<Person[]>([])\n let error = $state<{ row: number; col: string; msg: string } | null>(null)\n\n function validateRow(row: Person): string | null {\n if (row.age < 0 || row.age > 130) return 'Age must be between 0 and 130.'\n if (!/^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$/.test(row.email)) return 'Invalid email.'\n return null\n }\n\n $effect(() => {\n if (!api) return\n const snap = api.getData()\n for (let i = 0; i < snap.length; i++) {\n const msg = validateRow(snap[i]!)\n if (msg) {\n // revert by writing back the original\n const original = initial[i]\n if (original) {\n for (const key of Object.keys(original) as Array<keyof Person>) {\n if ((snap[i] as any)[key] !== (original as any)[key]) {\n api!.setCellValue(i, key as string, (original as any)[key])\n }\n }\n }\n error = { row: i, col: '*', msg }\n return\n }\n }\n error = null\n initial = snap.map((r) => ({ ...r }))\n })\n</script>\n\n{#if error}\n <p class=\"text-rose-600\">Row {error.row + 1}: {error.msg}</p>\n{/if}\n\n<SvGrid {data} {columns} features={features} enableInlineEditing\n onApiReady={(next) => (api = next)} />\n```\n\nThis polling-based validator works but has obvious limits:\n\n- The validator runs on every reactive tick, not strictly on commit.\n- The user briefly sees the invalid value before it reverts.\n\nA per-column `validate(value, row, column)` returning `string | true` is\non the [gap list](../missing-features.md).\n\n## Inline error UI\n\nRender an asterisk / red border via a custom cell renderer that reads\nyour validation state map. See [Highlighting changes](../cells/highlighting-changes.md)\nfor the same pattern with a \"dirty\" indicator - substitute \"invalid\" for\n\"dirty\".\n\n## See also\n\n- [Parsing values](./parsing-values.md)\n- [Saving values](./saving-values.md)\n- [demos/05-inline-editing.svelte](../../../examples/src/demos/05-inline-editing.svelte)\n"
3291
3312
  },
3292
3313
  {
3293
3314
  "slug": "help/errors",
@@ -3299,7 +3320,7 @@ export const docs = [
3299
3320
  "slug": "help/export",
3300
3321
  "path": "docs/help/export.md",
3301
3322
  "title": "Data export and printing",
3302
- "markdown": "# Data export and printing\r\n\r\n**CSV / TSV / JSON export and copy-to-clipboard are free** in the community\r\n**[@svgrid/grid](https://www.npmjs.com/package/@svgrid/grid)** - the commodity\r\n\"data out\" every grid needs. The richer formats - **Excel (xlsx)**, **legacy\r\nExcel (xls)**, **PDF**, styled **HTML**, **XML**, **Markdown**, multi-sheet\r\nworkbooks, password protection, conditional-format export, and the drop-in\r\nexport menu - ship in the paid\r\n**[@svgrid/enterprise](https://www.npmjs.com/package/@svgrid/enterprise)** add-on,\r\nwhich reuses the exact same serializers so the two tiers feel like one product.\r\n\r\n![Pick a row scope, then serialize the grid to Excel, CSV, TSV, HTML, PDF, or Print.](/docs-media/grid-export.svg)\r\n\r\n| Capability | Package |\r\n| ---------- | ------- |\r\n| CSV / TSV / JSON export, copy-to-clipboard (TSV / CSV / Markdown) | **Free** (`@svgrid/grid`) |\r\n| Excel `.xlsx` (typed cells, styles, conditional formatting, tables, multi-sheet) | Enterprise |\r\n| PDF, styled HTML, XML, legacy `.xls`, password-protected export | Enterprise |\r\n| `exportValue` hook, conditional-format export, `SvExportMenu`, `print()` | Enterprise |\r\n\r\n## Free: CSV / TSV / JSON + clipboard\r\n\r\nThe community grid's `SvGridApi` carries four zero-dependency methods - no\r\nlicense, no peer deps:\r\n\r\n```ts\r\nawait api.exportCsv({ filename: 'orders' }) // orders.csv (BOM + Excel-friendly)\r\nawait api.exportTsv() // grid.tsv\r\nawait api.exportJson({ rows: 'selected' }) // grid.json, checked rows only\r\nawait api.copyToClipboard({ format: 'tsv' }) // paste straight into Excel / Sheets\r\n```\r\n\r\nEvery method:\r\n\r\n- defaults to the **current view** (`rows: 'selected' | 'all'` to change),\r\n- formats values **as shown on screen** (currency, dates) - pass\r\n `rawValues: true` for the underlying values,\r\n- accepts a `columns: string[]` field subset (in that order),\r\n- runs on a chunked, cancelable loop (`signal`, `onProgress`) so a 100k-row\r\n export never freezes the tab,\r\n- returns the serialized text; pass `download: false` to skip the download\r\n and just get the string.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, tableFeatures, type SvGridApi } from '@svgrid/grid'\r\n let api = $state<SvGridApi<any, Row> | null>(null)\r\n</script>\r\n\r\n<button onclick={() => api?.exportCsv({ filename: 'orders' })}>Export CSV</button>\r\n<button onclick={() => api?.copyToClipboard()}>Copy for Excel</button>\r\n<SvGrid data={rows} columns={columns} features={features} onApiReady={(a) => (api = a)} />\r\n```\r\n\r\nThe rest of this page covers the **Enterprise** formats.\r\n\r\nTry the export bar below - downloads run in your browser; the bundled\r\nlicense key removes the unlicensed watermark:\r\n\r\n<div data-docs-demo=\"21-export-and-print\" data-height=\"500\"></div>\r\n\r\n\r\n## What it is\r\n\r\n`@svgrid/enterprise` augments the `SvGridApi` you already get from\r\n`<SvGrid onApiReady>` with two methods:\r\n\r\n- `api.exportData({ format, filename?, columns?, rows?, pageOrientation? })`\r\n- `api.print({ title?, columns?, rows?, orientation? })`\r\n\r\nBoth methods default to **the currently displayed rows** - sort, filter,\r\nor paginate the grid, and the export reflects that view automatically.\r\n\r\n## When to use it\r\n\r\n- Reporting flows where users want to take the grid offline (spreadsheets,\r\n emailed PDFs).\r\n- Compliance / audit trails that require a printable artifact.\r\n- Quick CSV/TSV pulls for downstream pipelines.\r\n\r\nIf you only need machine-readable data, prefer CSV / TSV - they have no\r\npeer dependencies and produce the smallest files. Use xlsx / PDF only\r\nwhen the recipient expects formatted documents.\r\n\r\n## Minimal example\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, tableFeatures, rowSortingFeature, type SvGridApi, type ColumnDef } from '@svgrid/grid'\r\n import { installEnterprise, setLicenseKey, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\n // Set the license key once at startup. Without a key, the feature still\r\n // works but the grid shows an \"unlicensed\" watermark and the console\r\n // emits a one-time nudge directing users to the pricing page.\r\n setLicenseKey('SVENTERPRISE-XXXX-XXXX-XXXX')\r\n\r\n const features = tableFeatures({ rowSortingFeature })\r\n\r\n type Order = { company: string; product: string; price: number }\r\n const rows: Order[] = [\r\n { company: 'ACME', product: 'Widget', price: 19.95 },\r\n { company: 'Globex', product: 'Gadget', price: 49.00 },\r\n ]\r\n const columns: ColumnDef<typeof features, Order>[] = [\r\n { field: 'company', header: 'Company' },\r\n { field: 'product', header: 'Product' },\r\n { field: 'price', header: 'Price', format: { type: 'currency', currency: 'USD' } },\r\n ]\r\n\r\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n\r\n function onReady(next: SvGridApi<typeof features, Order>) {\r\n api = installEnterprise(next)\r\n }\r\n</script>\r\n\r\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'orders' })}>\r\n Export Excel\r\n</button>\r\n<button onclick={() => api?.exportData({ format: 'pdf', filename: 'orders', pageOrientation: 'landscape' })}>\r\n Export PDF\r\n</button>\r\n<button onclick={() => api?.print({ title: 'Orders' })}>\r\n Print\r\n</button>\r\n\r\n<SvGrid data={rows} columns={columns} features={features} onApiReady={onReady} />\r\n```\r\n\r\n## Install\r\n\r\n```bash\r\npnpm add @svgrid/enterprise\r\n# Optional - install only the peers you actually use:\r\npnpm add jszip # required for xlsx\r\npnpm add pdfmake # required for pdf\r\n```\r\n\r\nCSV, TSV, HTML, and Print have **no extra dependencies**. The peer\r\ndependencies are lazy-loaded only when you call the format that needs\r\nthem.\r\n\r\n## Licensing\r\n\r\n`@svgrid/enterprise` has a tiered license gate:\r\n\r\n| Key state | Behavior |\r\n| ------------------------------------------ | -------- |\r\n| No key set (`setLicenseKey()` not called) | Feature works. Grid shows an unlicensed watermark linking to jqwidgets.com; console.log emits a one-time nudge. |\r\n| Key doesn't start with `SVENTERPRISE-` | Throws - programmer error. |\r\n| Key is in the revoked list | Throws - contact support for a replacement. |\r\n| `SVENTERPRISE-DEV-...` or `SVENTERPRISE-EVAL-...` | Works. One-time console.info notice. No watermark. |\r\n| Any other `SVENTERPRISE-...` | Works silently. |\r\n\r\nBuy a production key at <https://svgrid.com/pricing/> ($599 / developer /\r\nyear). `SVENTERPRISE-DEV-...` and `SVENTERPRISE-EVAL-...` keys cover local development\r\nand 14-day trials respectively.\r\n\r\n## Reference\r\n\r\n### `setLicenseKey(key: string): void`\r\n\r\nStores the key in module state. Call once at app startup (e.g. in\r\n`main.ts`). Subsequent calls overwrite.\r\n\r\n### `clearLicenseKey(): void` Ā· `hasValidLicense(): boolean` Ā· `dismissUnlicensedNudge(): void`\r\n\r\nProgrammatic helpers. `hasValidLicense()` is useful when you want UI to\r\nbranch on license status. `dismissUnlicensedNudge()` removes the\r\nwatermark and stops the MutationObserver - call it after setting a\r\nvalid key if you toggled the soft-gate during testing.\r\n\r\n### `installEnterprise(api): EnterpriseGridApi`\r\n\r\nMutates the given `SvGridApi` to add `exportData` and `print`. Returns\r\nthe same object with the augmented type, so existing references keep\r\nworking.\r\n\r\n### `api.exportData(opts)` - `Promise<void>`\r\n\r\n| Option | Type | Default | Notes |\r\n| ----------------- | ----------------------------------------------------- | -------------------- | ----- |\r\n| `format` | `'xlsx' \\| 'xls' \\| 'pdf' \\| 'csv' \\| 'tsv' \\| 'html' \\| 'json' \\| 'xml' \\| 'md'` | required | `xlsx` needs `jszip`, `pdf` needs `pdfmake`. `xls` / CSV / TSV / HTML / JSON / XML / Markdown are dependency-free. |\r\n| `pdf` | `PdfExportOptions` | - | PDF layout: page size, margins, title / subtitle / logo, theme colors, column widths, repeated header, page numbers. See \"PDF options\". |\r\n| `conditionalFormats` | `ConditionalFormat[]` | - | Same array as `<SvGrid conditionalFormats>`; carried into pdf / xlsx / html. See \"Conditional formatting in exports\". |\r\n| `freezeHeader` | `boolean` | `true` | Freeze the header row (xlsx / xls). |\r\n| `freezeColumns` | `number` | pinned cols | Freeze this many leading columns (xlsx / xls). Defaults to the grid's left-pinned columns. |\r\n| `autoFitColumns` | `boolean` | `true` | Size columns to content (xlsx / xls); a column's explicit `width` wins. |\r\n| `precisionSafe` | `boolean` | `false` | Export long integer IDs (> 15 digits) as text so Excel's float precision doesn't round them (xlsx / xls). |\r\n| `excelTable` | `boolean \\| { totalsRow?; style? }` | - | Wrap single-sheet xlsx in a native Excel Table (filter dropdowns, banded rows; `{ totalsRow: true }` adds SUM formulas). |\r\n| `download` | `boolean` | `true` | When false, return the {@link ExportResult} without downloading. |\r\n| `filename` | `string` | `\"grid\"` | Extension is appended if missing. |\r\n| `columns` | `{ field; header?; format?; align?; exportValue? }[]` | grid's own columns | Drives column selection + header labels. Auto-derived from the grid (with each column's `format`) when omitted. See `exportValue` below. |\r\n| `rows` | `ReadonlyArray<TData> \\| 'displayed' \\| 'selected' \\| 'all'` | `'displayed'` | `'displayed'` = current filtered/sorted/paged view, `'selected'` = checked rows, `'all'` = full dataset, or pass an explicit array. |\r\n| `rawValues` | `boolean` | `false` | By default the exporter writes the **formatted display value** (what you see). Set `true` to write raw underlying values (numbers stay numeric in xlsx). |\r\n| `autoGroup` | `boolean` | `true` | Carry the grid's active row grouping into xlsx outline rows. Pass your own `groupBy` to override, or `false` to ignore. |\r\n| `csv` | `{ delimiter?; eol?; bom? }` | `{ ',', '\\r\\n', BOM }` | CSV / TSV tuning. UTF-8 BOM is on by default so Excel opens it without mojibake. |\r\n| `onProgress` | `(p) => void` | - | Progress for large exports: `{ phase, ratio, row?, total? }`, `phase` = `'project' \\| 'serialize' \\| 'write'`. |\r\n| `signal` | `AbortSignal` | - | Cancel an in-flight export (checked between chunks). Rejects with `AbortError`. |\r\n| `pageOrientation` | `'portrait' \\| 'landscape'` | `\"portrait\"` | PDF only. |\r\n| `merges` | `{ row; col; rowSpan?; colSpan? }[]` | `[]` | Merged cells (xlsx / pdf). Zero-based **body** row/col (header excluded). Single-sheet only; mutually exclusive with `groupBy` / `hierarchical`. |\r\n\r\n### Faithful values (what you see is what you export)\r\n\r\nBy default the export renders each cell the way the grid does on screen: a\r\ncolumn with `format: { type: 'currency', currency: 'USD' }` exports `$19.95`,\r\na date column exports its formatted date, a percent column exports `42%`. This\r\nholds for **every** format (csv / tsv / html / pdf / xlsx). Pass\r\n`rawValues: true` to write the underlying values instead (e.g. feeding a data\r\npipeline that wants the numeric `19.95`).\r\n\r\nFor columns whose on-screen value comes from a **custom cell renderer**, a\r\n`fieldFn`, or a lookup, give the column an `exportValue` hook - it takes\r\nprecedence over `format` and the raw field:\r\n\r\n```ts\r\nawait api.exportData({\r\n format: 'xlsx',\r\n columns: [\r\n { field: 'company', header: 'Company' },\r\n // a column rendered with a custom snippet on screen:\r\n { field: 'status', header: 'Status', exportValue: (row) => STATUS_LABELS[row.status] },\r\n ],\r\n})\r\n```\r\n\r\n### Export only what's selected\r\n\r\n```ts\r\n// current view (default)\r\nawait api.exportData({ format: 'xlsx' })\r\n// only the checked rows\r\nawait api.exportData({ format: 'xlsx', rows: 'selected' })\r\n// the entire underlying dataset, ignoring filters/paging\r\nawait api.exportData({ format: 'csv', rows: 'all' })\r\n```\r\n\r\n### Large exports: progress + cancel\r\n\r\nCSV / TSV / HTML are serialized natively (no peer dependency) and stream in\r\nchunks, yielding to the event loop so a big export doesn't freeze the tab:\r\n\r\n```ts\r\nconst controller = new AbortController()\r\nawait api.exportData({\r\n format: 'csv',\r\n rows: 'all',\r\n signal: controller.signal,\r\n onProgress: ({ ratio }) => updateProgressBar(ratio),\r\n})\r\n// controller.abort() rejects the export with an AbortError\r\n```\r\n\r\n### Excel-native numbers (xlsx)\r\n\r\nA single-sheet `xlsx` export writes numeric, currency, percent, and date\r\ncolumns as **real typed cells** with an Excel number format - so Excel can sum\r\nand sort them, and they still display formatted (currency symbol, thousands,\r\n`%`, date pattern) - via SvGrid's own OOXML writer. Set `rawValues: true` for\r\nunformatted raw values. The grouped-outline, multi-sheet, image, and merged-cell\r\npaths use the bundled writer (formatted-string cells). The legacy `xls` format\r\nis always typed.\r\n\r\n### Excel fidelity: freeze, auto-fit, hyperlinks\r\n\r\n`xlsx` and `xls` exports polish the spreadsheet automatically:\r\n\r\n- **Freeze header** - the header row stays visible when scrolling. On by\r\n default; `freezeHeader: false` to disable, `freezeColumns: n` to also freeze\r\n leading columns.\r\n- **Auto-fit column widths** - columns are sized to their content. On by\r\n default (`autoFitColumns: false` to disable); a column's explicit `width`\r\n (pixels) always wins.\r\n- **Hyperlinks** - give a column a `link(row)` hook to render clickable links.\r\n Works in `xlsx`, `xls`, `html`, `pdf`, and `print`.\r\n\r\nSingle-sheet `xlsx` is written by SvGrid's own OOXML writer, so numbers/dates\r\nstay typed with number formats, conditional-format colors are **real per-cell\r\nfills**, and freeze/widths/hyperlinks all apply. (Grouped/outline, multi-sheet,\r\nembedded-image, and merged-cell exports use the bundled writer.)\r\n\r\n```ts\r\nawait api.exportData({\r\n format: 'xlsx',\r\n freezeHeader: true, // default\r\n columns: [\r\n { field: 'name', header: 'Company', width: 220,\r\n link: (row) => `https://crm.example.com/accounts/${row.id}` },\r\n { field: 'revenue', header: 'Revenue', format: { type: 'currency', currency: 'USD' } },\r\n ],\r\n})\r\n```\r\n\r\nConditional-format colors (data bars / color scales) already render as real\r\ncell fills in `xlsx`, which is higher fidelity than the writer's built-in\r\nrule-only conditional formatting - so we keep the per-cell approach.\r\n\r\n### Get the file instead of downloading (`download: false`)\r\n\r\n`exportData` returns an **`ExportResult`** for the dependency-free paths\r\n(csv/tsv/html/json/xml/md, xls, pdf, single-sheet xlsx) so you can preview,\r\nupload, email, or attach the file. With `download: false` it builds and returns\r\nthe file **without** triggering a browser download:\r\n\r\n```ts\r\nconst res = await api.exportData({ format: 'xlsx', download: false })\r\n// res: { blob, filename, mime, rowCount, byteSize }\r\nawait uploadToStorage(res.blob, res.filename)\r\n```\r\n\r\nThe grouped / multi-sheet / image / blanket-styled xlsx paths use the vendored\r\nwriter, which downloads directly and returns `undefined` (and throws on\r\n`download: false`).\r\n\r\n### Excel Table (filter dropdowns + totals row)\r\n\r\nWrap a single-sheet xlsx in a native **Excel Table** - the user gets column\r\nfilter dropdowns, banded rows, structured references, and (optionally) a totals\r\nrow with live `SUM()` formulas:\r\n\r\n```ts\r\nawait api.exportData({ format: 'xlsx', excelTable: { totalsRow: true } })\r\n// or just `excelTable: true` for the table without a totals row\r\n```\r\n\r\nColumn widths auto-fit and leading **pinned columns are frozen automatically**\r\n(override with `freezeColumns`). For long IDs, `precisionSafe: true` writes them\r\nas text so Excel's 15-digit float limit doesn't silently round `1002000300040005`.\r\n\r\n### JSON / XML / Markdown\r\n\r\n```ts\r\nawait api.exportData({ format: 'json' }) // array of { field: value } - defaults to raw values\r\nawait api.exportData({ format: 'xml' }) // <rows><row><field>value</field>…\r\nawait api.exportData({ format: 'md' }) // GitHub-flavored Markdown table (great for docs / LLMs)\r\n```\r\n\r\nJSON defaults to **raw** values (real numbers/dates) since it's a data format;\r\npass `rawValues: false` for formatted strings. XML and Markdown use the\r\nformatted display values.\r\n\r\n### Copy to clipboard\r\n\r\n`api.copyExport(opts)` serializes to the clipboard instead of downloading a\r\nfile - handy for \"copy these rows into a spreadsheet or an email\":\r\n\r\n```ts\r\nawait api.copyExport({ format: 'tsv' }) // pastes into Excel / Sheets as columns (default)\r\nawait api.copyExport({ format: 'md', rows: 'selected' }) // Markdown table of the checked rows\r\nawait api.copyExport({ format: 'html' }) // rich text/html - keeps the table when pasted\r\n```\r\n\r\nSupported formats: `csv`, `tsv`, `html`, `json`, `xml`, `md`. `html` writes\r\nboth `text/html` (rich paste) and a `text/plain` fallback. Needs a secure\r\ncontext and a user gesture (call it from a click).\r\n\r\n### Conditional formatting in exports\r\n\r\nPass the same `conditionalFormats` array you give `<SvGrid>` to carry the grid's\r\ncolor scales, data bars, icon sets, and predicate rules into the **styled**\r\nformats - PDF, xlsx, and HTML (and `print()`):\r\n\r\n```ts\r\nconst conditionalFormats = [\r\n { type: 'colorScale', columns: ['score'], min: '#dcfce7', max: '#fee2e2' },\r\n { type: 'rule', columns: ['status'], when: ({ value }) => value === 'Overdue', background: '#fecaca' },\r\n]\r\n\r\nawait api.exportData({ format: 'pdf', conditionalFormats })\r\nawait api.exportData({ format: 'xlsx', conditionalFormats }) // real cell fills in Excel\r\nawait api.print({ conditionalFormats })\r\n```\r\n\r\nCell background, text color, and bold carry through as **real cell fills** in\r\n`xlsx` (single-sheet), `xls`, `html`, and `pdf`; icon sets prepend their glyph\r\nto the cell text.\r\n\r\nIn **xlsx**, **data bars** and **color scales** become **native Excel\r\nconditional formatting** (`<conditionalFormatting>` rules) - Excel draws and\r\nrecomputes them itself, so they're interactive, not a static snapshot.\r\nPredicate `rule` formats stay as computed per-cell fills. In the other styled\r\nformats (`xls` / `html` / `pdf`) a data bar degrades to a light fill of the bar\r\ncolor (cells can't hold a partial-width bar). The data formats (csv / tsv /\r\njson / xml / md) are unstyled, so conditional formatting is ignored there.\r\nGrouped PDF exports skip conditional formatting (the group/subtotal rows own the\r\nlayout).\r\n\r\n### PDF options\r\n\r\nThe PDF is built from a full pdfmake document you control via `pdf`:\r\n\r\n```ts\r\nawait api.exportData({\r\n format: 'pdf',\r\n filename: 'orders',\r\n pdf: {\r\n pageSize: 'A4', // 'A4' | 'A3' | 'A5' | 'LETTER' | 'LEGAL'\r\n pageOrientation: 'landscape',\r\n title: 'Q3 Orders',\r\n subtitle: 'Generated by Ops',\r\n logo: logoDataUrl, // top-left image (data URL)\r\n headerColor: '#334155', // header row fill\r\n zebra: true, // striped rows\r\n columnWidths: '*', // '*' fill, 'auto', or a per-column pt array\r\n // repeatHeader (default true), showPageNumbers (default true) also available\r\n },\r\n})\r\n```\r\n\r\nThe header row repeats on every page, each page gets a `Page X of Y` +\r\ndate footer, per-column alignment is honored, and wide grids auto-switch to\r\nlandscape (override with `pdf.pageOrientation`).\r\n\r\nWhen the grid is grouped, the PDF carries the grouping: a bold **group header**\r\nper cluster (nested for multi-level grouping) and a **subtotal row** summing the\r\nnumber / currency columns - matching the xlsx outline export. It's on by\r\ndefault (`autoGroup`); pass `groupBy` to override or `autoGroup: false` to get a\r\nflat table.\r\n\r\n#### Merged cells\r\n\r\nPass `merges` to write real merged regions into the sheet. Row / column\r\nindices are zero-based over the exported **body** (the header row is not\r\ncounted), and the shape lines up with the grid's own `MergeSpec`:\r\n\r\n```ts\r\n// Merge the first two data rows of column 0, and span a 3-column banner\r\nawait api.exportData({\r\n format: 'xlsx',\r\n merges: [\r\n { row: 0, col: 0, rowSpan: 2 }, // vertical merge (e.g. a repeated group key)\r\n { row: 5, col: 0, colSpan: 3 }, // horizontal merge (a section banner)\r\n ],\r\n})\r\n```\r\n\r\nTo export the merges you already show in the grid, take your `MergeSpec[]`\r\n(`{ rowIndex, columnId, rowspan, colspan }`) and map `columnId` to its column\r\nindex: `{ row: m.rowIndex, col: colIndex(m.columnId), rowSpan: m.rowspan, colSpan: m.colspan }`.\r\n\r\nThrows on missing peer (`jszip` / `pdfmake`), revoked / malformed\r\nlicense, or empty result set. With no license set, it runs but the\r\ngrid is watermarked.\r\n\r\n### `api.print(opts?)` - `Promise<void>`\r\n\r\nOpens a new window with a paginated, printable rendering of the grid and\r\ntriggers the browser print dialog. This is the **zero-dependency \"Save as PDF\"**\r\nroute: it uses the browser's own engine, so it has excellent font + CSS\r\nfidelity (including CJK / RTL) and needs no `pdfmake`. The header row repeats on\r\nevery page, values print **formatted** (matching the grid), and columns keep\r\ntheir alignment.\r\n\r\n| Option | Type | Default |\r\n| ------------- | -------------------------------------- | ----------- |\r\n| `title` | `string` | `\"Grid\"` |\r\n| `subtitle` | `string` | - |\r\n| `logo` | `string` (data URL) | - |\r\n| `columns` | `{ field; header?; format?; align? }[]`| grid's own columns |\r\n| `rows` | `ReadonlyArray<TData> \\| 'displayed' \\| 'selected' \\| 'all'` | `'displayed'` |\r\n| `rawValues` | `boolean` | `false` |\r\n| `orientation` | `'portrait' \\| 'landscape'` | `\"portrait\"` |\r\n| `pageSize` | `string` (e.g. `'A4'`, `'Letter'`) | browser default |\r\n| `margin` | `string` (e.g. `'14mm'`) | `'14mm'` |\r\n| `zebra` | `boolean` | `true` |\r\n| `headerColor` | `string` | `'#f1f5f9'` |\r\n\r\nBrowsers may block the popup unless `print()` is called from a user\r\ngesture (a click handler is fine - automatic on-load print is not). Page\r\nnumbers come from the browser's own print header/footer.\r\n\r\n**`exportData({ format: 'pdf' })` vs `print()`** - use PDF export for a\r\nprogrammatic, silent file download with full layout control (see \"PDF\r\noptions\"); use `print()` when you want best-fidelity output and the user to\r\npick print or \"Save as PDF\" from the dialog.\r\n\r\n## Drop-in Export menu (`SvExportMenu`)\r\n\r\nRather than wiring your own toolbar, drop in the bundled `SvExportMenu`\r\ncomponent. It renders an \"Export\" button with a format picker, a row-scope\r\ntoggle (current view / selected / all), and a built-in progress bar + Cancel\r\nfor large exports:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, type SvGridApi } from '@svgrid/grid'\r\n import { installEnterprise, SvExportMenu, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n</script>\r\n\r\n<div class=\"toolbar\">\r\n <SvExportMenu {api} filename=\"orders\" />\r\n</div>\r\n\r\n<SvGrid data={rows} columns={columns} features={features}\r\n onApiReady={(a) => (api = installEnterprise(a))} />\r\n```\r\n\r\n| Prop | Type | Default | Notes |\r\n| ------------ | ------------------------------------------------ | --------------------------- | ----- |\r\n| `api` | `EnterpriseGridApi \\| null` | required | Button is disabled until the api is ready. |\r\n| `filename` | `string` | `'grid'` | Base name, no extension. |\r\n| `formats` | `ExportFormat[]` | xlsx/xls/pdf/csv/tsv/html | Which formats to offer, in order. |\r\n| `allowScope` | `boolean` | `true` | Show the current-view / selected / all toggle. |\r\n| `allowColumns` | `boolean` | `true` | Show per-column include checkboxes (choose which columns to export). |\r\n| `allowCopy` | `boolean` | `true` | Show \"Copy for Excel\" (tsv) + \"Copy Markdown\" clipboard actions. |\r\n| `allowPrint` | `boolean` | `false` | Show a \"Print…\" action (`api.print`). |\r\n| `scope` | `'displayed' \\| 'selected' \\| 'all'` | `'displayed'` | Initial row scope. |\r\n| `label` | `string` | `'Export'` | Button text. |\r\n| `conditionalFormats` | `ConditionalFormat[]` | - | Carried into pdf / xlsx / html exports. |\r\n\r\nThe menu is sectioned into **Rows** (scope), **Columns** (a picker), and\r\n**Download** (each format shown with an icon + one-line description), plus\r\n**copy-to-clipboard** and optional **print** actions. It's **keyboard\r\naccessible** (arrow keys move between items, Tab is trapped inside, Esc closes\r\nand returns focus to the button, with `role=\"menu\"` / `menuitem`), handles\r\nerrors inline (missing peer dependency, empty grid), and wires `onProgress` +\r\nan `AbortController` for you.\r\n\r\nFor large exports, the single-sheet xlsx writer builds in **chunks that yield to\r\nthe event loop** (and zips asynchronously), so the tab stays responsive and\r\n`onProgress` ticks; pass a `signal` to cancel.\r\n\r\n## Gotchas\r\n\r\n- **Empty grids** - both `exportData` and `print` throw if there are no\r\n displayed rows. Catch the error and show a notice in the UI.\r\n- **Column ordering** - if you don't pass `columns`, the export uses\r\n `Object.keys(rows[0])` order. Pass `columns` explicitly when the row\r\n shape doesn't match the column order you want.\r\n- **Cell formatters** - column-level format hints (date, number, currency,\r\n percent) are applied to the exported value by default, so the file matches\r\n the grid. Custom snippet renderers are **not** auto-serialized - give those\r\n columns an `exportValue` hook (see \"Faithful values\" above), or set\r\n `rawValues: true` if you want the raw underlying values.\r\n- **Print popup blocked** - `print()` resolves but the browser silently\r\n blocks the new window. Always trigger from a user click, and surface\r\n the thrown error.\r\n- **Bundle size** - the vendored exporter is ~50 KB minified. It is\r\n loaded lazily on first call so it does not bloat the initial bundle\r\n for users who never export.\r\n\r\n## Frequently asked questions\r\n\r\n### How do I export a Svelte data grid to Excel?\r\n\r\nInstall `@svgrid/enterprise`, call `installEnterprise(api)`, then call the export helper with\r\n`format: 'xlsx'`. The exporter writes a real OOXML workbook in the browser - no\r\nserver round-trip. CSV, TSV, PDF, and HTML use the same call with a different\r\nformat.\r\n\r\n### What's the difference between `xls` and `xlsx`?\r\n\r\n`xlsx` is the modern OOXML workbook (Excel 2007+); it needs the `jszip` peer\r\ndependency and supports the richest output (styles, images, multi-sheet,\r\nformulas). `xls` is the **legacy Excel 2003 XML Spreadsheet** format: pick it\r\nfor maximum compatibility with old Excel installs or systems that only accept\r\n`.xls`. It has **no peer dependency**, streams in the browser, and keeps\r\nnumbers and dates typed (so sums and sorting still work in Excel) with number\r\nformats applied. It's single-sheet - use `xlsx` for multi-tab workbooks.\r\n\r\n```ts\r\nawait api.exportData({ format: 'xls', filename: 'orders' })\r\n```\r\n\r\n### Is export part of the free Community package?\r\n\r\nNo. Export and printing ship in the paid `@svgrid/enterprise` add-on. The free\r\n`@svgrid/grid` package covers the full grid (sorting, filtering, grouping,\r\nediting, virtualization) but not export/print/pivot/import.\r\n\r\n### Does exporting bloat my bundle?\r\n\r\nNo. The ~50 KB exporter is lazy-loaded on the first export call, so users who\r\nnever export never download it.\r\n"
3323
+ "markdown": "# Data export and printing\r\n\r\n**CSV / TSV / JSON export and copy-to-clipboard are free** in the community\r\n**[@svgrid/grid](https://www.npmjs.com/package/@svgrid/grid)** - the commodity\r\n\"data out\" every grid needs. The richer formats - **Excel (xlsx)**, **legacy\r\nExcel (xls)**, **PDF**, styled **HTML**, **XML**, **Markdown**, multi-sheet\r\nworkbooks, password protection, conditional-format export, and the drop-in\r\nexport menu - ship in the paid\r\n**[@svgrid/enterprise](https://www.npmjs.com/package/@svgrid/enterprise)** add-on,\r\nwhich reuses the exact same serializers so the two tiers feel like one product.\r\n\r\n![Pick a row scope, then serialize the grid to Excel, CSV, TSV, HTML, PDF, or Print.](/docs-media/grid-export.svg)\r\n\r\n| Capability | Package |\r\n| ---------- | ------- |\r\n| CSV / TSV / JSON export, copy-to-clipboard (TSV / CSV / Markdown) | **Free** (`@svgrid/grid`) |\r\n| Excel `.xlsx` (typed cells, styles, conditional formatting, tables, multi-sheet) | Enterprise |\r\n| PDF, styled HTML, XML, legacy `.xls`, password-protected export | Enterprise |\r\n| `exportValue` hook, conditional-format export, `SvExportMenu`, `print()` | Enterprise |\r\n\r\n## Free: CSV / TSV / JSON + clipboard\r\n\r\nThe community grid's `SvGridApi` carries four zero-dependency methods - no\r\nlicense, no peer deps:\r\n\r\n```ts\r\nawait api.exportCsv({ filename: 'orders' }) // orders.csv (BOM + Excel-friendly)\r\nawait api.exportTsv() // grid.tsv\r\nawait api.exportJson({ rows: 'selected' }) // grid.json, checked rows only\r\nawait api.copyToClipboard({ format: 'tsv' }) // paste straight into Excel / Sheets\r\n```\r\n\r\nEvery method:\r\n\r\n- defaults to the **current view** (`rows: 'selected' | 'all'` to change),\r\n- formats values **as shown on screen** (currency, dates) - pass\r\n `rawValues: true` for the underlying values,\r\n- accepts a `columns: string[]` field subset (in that order),\r\n- runs on a chunked, cancelable loop (`signal`, `onProgress`) so a 100k-row\r\n export never freezes the tab,\r\n- returns the serialized text; pass `download: false` to skip the download\r\n and just get the string.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, tableFeatures, type SvGridApi } from '@svgrid/grid'\r\n let api = $state<SvGridApi<any, Row> | null>(null)\r\n</script>\r\n\r\n<button onclick={() => api?.exportCsv({ filename: 'orders' })}>Export CSV</button>\r\n<button onclick={() => api?.copyToClipboard()}>Copy for Excel</button>\r\n<SvGrid data={rows} columns={columns} features={features} onApiReady={(a) => (api = a)} />\r\n```\r\n\r\nThe rest of this page covers the **Enterprise** formats.\r\n\r\nTry the export bar below - downloads run in your browser; the bundled\r\nlicense key removes the unlicensed watermark:\r\n\r\n<div data-docs-demo=\"21-export-and-print\" data-height=\"500\"></div>\r\n\r\n\r\n## What it is\r\n\r\n`@svgrid/enterprise` augments the `SvGridApi` you already get from\r\n`<SvGrid onApiReady>` with two methods:\r\n\r\n- `api.exportData({ format, filename?, columns?, rows?, pageOrientation? })`\r\n- `api.print({ title?, columns?, rows?, orientation? })`\r\n\r\nBoth methods default to **the currently displayed rows** - sort, filter,\r\nor paginate the grid, and the export reflects that view automatically.\r\n\r\n## When to use it\r\n\r\n- Reporting flows where users want to take the grid offline (spreadsheets,\r\n emailed PDFs).\r\n- Compliance / audit trails that require a printable artifact.\r\n- Quick CSV/TSV pulls for downstream pipelines.\r\n\r\nIf you only need machine-readable data, prefer CSV / TSV - they have no\r\npeer dependencies and produce the smallest files. Use xlsx / PDF only\r\nwhen the recipient expects formatted documents.\r\n\r\n## Minimal example\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, tableFeatures, rowSortingFeature, type SvGridApi, type ColumnDef } from '@svgrid/grid'\r\n import { installEnterprise, setLicenseKey, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\n // Set the license key once at startup. Without a key, the feature still\r\n // works but the grid shows an \"unlicensed\" watermark and the console\r\n // emits a one-time nudge directing users to the pricing page.\r\n setLicenseKey('SVENTERPRISE-XXXX-XXXX-XXXX')\r\n\r\n const features = tableFeatures({ rowSortingFeature })\r\n\r\n type Order = { company: string; product: string; price: number }\r\n const rows: Order[] = [\r\n { company: 'ACME', product: 'Widget', price: 19.95 },\r\n { company: 'Globex', product: 'Gadget', price: 49.00 },\r\n ]\r\n const columns: ColumnDef<typeof features, Order>[] = [\r\n { field: 'company', header: 'Company' },\r\n { field: 'product', header: 'Product' },\r\n { field: 'price', header: 'Price', format: { type: 'currency', currency: 'USD' } },\r\n ]\r\n\r\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n\r\n function onReady(next: SvGridApi<typeof features, Order>) {\r\n api = installEnterprise(next)\r\n }\r\n</script>\r\n\r\n<button onclick={() => api?.exportData({ format: 'xlsx', filename: 'orders' })}>\r\n Export Excel\r\n</button>\r\n<button onclick={() => api?.exportData({ format: 'pdf', filename: 'orders', pageOrientation: 'landscape' })}>\r\n Export PDF\r\n</button>\r\n<button onclick={() => api?.print({ title: 'Orders' })}>\r\n Print\r\n</button>\r\n\r\n<SvGrid data={rows} columns={columns} features={features} onApiReady={onReady} />\r\n```\r\n\r\n## Install\r\n\r\n```bash\r\npnpm add @svgrid/enterprise\r\n# Optional - install only the peers you actually use:\r\npnpm add jszip # required for xlsx\r\npnpm add pdfmake # required for pdf\r\n```\r\n\r\nCSV, TSV, HTML, and Print have **no extra dependencies**. The peer\r\ndependencies are lazy-loaded only when you call the format that needs\r\nthem.\r\n\r\n## Licensing\r\n\r\n`@svgrid/enterprise` has a tiered license gate:\r\n\r\n| Key state | Behavior |\r\n| ------------------------------------------ | -------- |\r\n| No key set (`setLicenseKey()` not called) | Feature works. Grid shows an unlicensed watermark linking to jqwidgets.com; console.log emits a one-time nudge. |\r\n| Key doesn't start with `SVENTERPRISE-` | Throws - programmer error. |\r\n| Key is in the revoked list | Throws - contact support for a replacement. |\r\n| `SVENTERPRISE-DEV-...` or `SVENTERPRISE-EVAL-...` | Works. One-time console.info notice. No watermark. |\r\n| Any other `SVENTERPRISE-...` | Works silently. |\r\n\r\nBuy a production key at <https://svgrid.com/pricing/> ($599 / developer /\r\nyear). `SVENTERPRISE-DEV-...` and `SVENTERPRISE-EVAL-...` keys cover local development\r\nand 14-day trials respectively.\r\n\r\n## Reference\r\n\r\n### `setLicenseKey(key: string): void`\r\n\r\nStores the key in module state. Call once at app startup (e.g. in\r\n`main.ts`). Subsequent calls overwrite.\r\n\r\n### `clearLicenseKey(): void` Ā· `hasValidLicense(): boolean` Ā· `dismissUnlicensedNudge(): void`\r\n\r\nProgrammatic helpers. `hasValidLicense()` is useful when you want UI to\r\nbranch on license status. `dismissUnlicensedNudge()` removes the\r\nwatermark and stops the MutationObserver - call it after setting a\r\nvalid key if you toggled the soft-gate during testing.\r\n\r\n### `installEnterprise(api): EnterpriseGridApi`\r\n\r\nMutates the given `SvGridApi` to add `exportData` and `print`. Returns\r\nthe same object with the augmented type, so existing references keep\r\nworking.\r\n\r\n### `api.exportData(opts)` - `Promise<void>`\r\n\r\n| Option | Type | Default | Notes |\r\n| ----------------- | ----------------------------------------------------- | -------------------- | ----- |\r\n| `format` | `'xlsx' \\| 'xls' \\| 'pdf' \\| 'csv' \\| 'tsv' \\| 'html' \\| 'json' \\| 'xml' \\| 'md'` | required | `xlsx` needs `jszip`, `pdf` needs `pdfmake`. `xls` / CSV / TSV / HTML / JSON / XML / Markdown are dependency-free. |\r\n| `pdf` | `PdfExportOptions` | - | PDF layout: page size, margins, title / subtitle / logo, theme colors, column widths, repeated header, page numbers. See \"PDF options\". |\r\n| `conditionalFormats` | `ConditionalFormat[]` | - | Same array as `<SvGrid conditionalFormats>`; carried into pdf / xlsx / html. See \"Conditional formatting in exports\". |\r\n| `freezeHeader` | `boolean` | `true` | Freeze the header row (xlsx / xls). |\r\n| `freezeColumns` | `number` | pinned cols | Freeze this many leading columns (xlsx / xls). Defaults to the grid's left-pinned columns. |\r\n| `autoFitColumns` | `boolean` | `true` | Size columns to content (xlsx / xls); a column's explicit `width` wins. |\r\n| `precisionSafe` | `boolean` | `false` | Export long integer IDs (> 15 digits) as text so Excel's float precision doesn't round them (xlsx / xls). |\r\n| `excelTable` | `boolean \\| { totalsRow?; style? }` | - | Wrap single-sheet xlsx in a native Excel Table (filter dropdowns, banded rows; `{ totalsRow: true }` adds SUM formulas). |\r\n| `download` | `boolean` | `true` | When false, return the {@link ExportResult} without downloading. |\r\n| `filename` | `string` | `\"grid\"` | Extension is appended if missing. |\r\n| `columns` | `{ field; header?; format?; align?; exportValue? }[]` | grid's own columns | Drives column selection + header labels. Auto-derived from the grid (with each column's `format`) when omitted. See `exportValue` below. |\r\n| `rows` | `ReadonlyArray<TData> \\| 'displayed' \\| 'selected' \\| 'all'` | `'displayed'` | `'displayed'` = current filtered/sorted/paged view, `'selected'` = checked rows, `'all'` = full dataset, or pass an explicit array. |\r\n| `rawValues` | `boolean` | `false` | By default the exporter writes the **formatted display value** (what you see). Set `true` to write raw underlying values (numbers stay numeric in xlsx). |\r\n| `autoGroup` | `boolean` | `true` | Carry the grid's active row grouping into xlsx outline rows. Pass your own `groupBy` to override, or `false` to ignore. |\r\n| `csv` | `{ delimiter?; eol?; bom? }` | `{ ',', '\\r\\n', BOM }` | CSV / TSV tuning. UTF-8 BOM is on by default so Excel opens it without mojibake. |\r\n| `onProgress` | `(p) => void` | - | Progress for large exports: `{ phase, ratio, row?, total? }`, `phase` = `'project' \\| 'serialize' \\| 'write'`. |\r\n| `signal` | `AbortSignal` | - | Cancel an in-flight export (checked between chunks). Rejects with `AbortError`. |\r\n| `pageOrientation` | `'portrait' \\| 'landscape'` | `\"portrait\"` | PDF only. |\r\n| `merges` | `{ row; col; rowSpan?; colSpan? }[]` | `[]` | Merged cells (xlsx / pdf). Zero-based **body** row/col (header excluded). Single-sheet only; mutually exclusive with `groupBy` / `hierarchical`. |\r\n\r\n### Faithful values (what you see is what you export)\r\n\r\nBy default the export renders each cell the way the grid does on screen: a\r\ncolumn with `format: { type: 'currency', currency: 'USD' }` exports `$19.95`,\r\na date column exports its formatted date, a percent column exports `42%`. This\r\nholds for **every** format (csv / tsv / html / pdf / xlsx). Pass\r\n`rawValues: true` to write the underlying values instead (e.g. feeding a data\r\npipeline that wants the numeric `19.95`).\r\n\r\nFor columns whose on-screen value comes from a **custom cell renderer**, a\r\n`fieldFn`, or a lookup, give the column an `exportValue` hook - it takes\r\nprecedence over `format` and the raw field:\r\n\r\n```ts\r\nawait api.exportData({\r\n format: 'xlsx',\r\n columns: [\r\n { field: 'company', header: 'Company' },\r\n // a column rendered with a custom snippet on screen:\r\n { field: 'status', header: 'Status', exportValue: (row) => STATUS_LABELS[row.status] },\r\n ],\r\n})\r\n```\r\n\r\n### Export only what's selected\r\n\r\n```ts\r\n// current view (default)\r\nawait api.exportData({ format: 'xlsx' })\r\n// only the checked rows\r\nawait api.exportData({ format: 'xlsx', rows: 'selected' })\r\n// the entire underlying dataset, ignoring filters/paging\r\nawait api.exportData({ format: 'csv', rows: 'all' })\r\n```\r\n\r\n### Large exports: progress + cancel\r\n\r\nCSV / TSV / HTML are serialized natively (no peer dependency) and stream in\r\nchunks, yielding to the event loop so a big export doesn't freeze the tab:\r\n\r\n```ts\r\nconst controller = new AbortController()\r\nawait api.exportData({\r\n format: 'csv',\r\n rows: 'all',\r\n signal: controller.signal,\r\n onProgress: ({ ratio }) => updateProgressBar(ratio),\r\n})\r\n// controller.abort() rejects the export with an AbortError\r\n```\r\n\r\n### Excel-native numbers (xlsx)\r\n\r\nA single-sheet `xlsx` export writes numeric, currency, percent, and date\r\ncolumns as **real typed cells** with an Excel number format - so Excel can sum\r\nand sort them, and they still display formatted (currency symbol, thousands,\r\n`%`, date pattern) - via SvGrid's own OOXML writer. Set `rawValues: true` for\r\nunformatted raw values. The grouped-outline, multi-sheet, image, and merged-cell\r\npaths use the bundled writer (formatted-string cells). The legacy `xls` format\r\nis always typed.\r\n\r\n### Excel fidelity: freeze, auto-fit, hyperlinks\r\n\r\n`xlsx` and `xls` exports polish the spreadsheet automatically:\r\n\r\n- **Freeze header** - the header row stays visible when scrolling. On by\r\n default; `freezeHeader: false` to disable, `freezeColumns: n` to also freeze\r\n leading columns.\r\n- **Auto-fit column widths** - columns are sized to their content. On by\r\n default (`autoFitColumns: false` to disable); a column's explicit `width`\r\n (pixels) always wins.\r\n- **Hyperlinks** - give a column a `link(row)` hook to render clickable links.\r\n Works in `xlsx`, `xls`, `html`, `pdf`, and `print`.\r\n\r\nSingle-sheet `xlsx` is written by SvGrid's own OOXML writer, so numbers/dates\r\nstay typed with number formats, conditional-format colors are **real per-cell\r\nfills**, and freeze/widths/hyperlinks all apply. (Grouped/outline, multi-sheet,\r\nembedded-image, and merged-cell exports use the bundled writer.)\r\n\r\n```ts\r\nawait api.exportData({\r\n format: 'xlsx',\r\n freezeHeader: true, // default\r\n columns: [\r\n { field: 'name', header: 'Company', width: 220,\r\n link: (row) => `https://crm.example.com/accounts/${row.id}` },\r\n { field: 'revenue', header: 'Revenue', format: { type: 'currency', currency: 'USD' } },\r\n ],\r\n})\r\n```\r\n\r\nConditional-format colors (data bars / color scales) already render as real\r\ncell fills in `xlsx`, which is higher fidelity than the writer's built-in\r\nrule-only conditional formatting - so we keep the per-cell approach.\r\n\r\n### Get the file instead of downloading (`download: false`)\r\n\r\n`exportData` returns an **`ExportResult`** for the dependency-free paths\r\n(csv/tsv/html/json/xml/md, xls, pdf, single-sheet xlsx) so you can preview,\r\nupload, email, or attach the file. With `download: false` it builds and returns\r\nthe file **without** triggering a browser download:\r\n\r\n```ts\r\nconst res = await api.exportData({ format: 'xlsx', download: false })\r\n// res: { blob, filename, mime, rowCount, byteSize }\r\nawait uploadToStorage(res.blob, res.filename)\r\n```\r\n\r\nThe grouped / multi-sheet / image / blanket-styled xlsx paths use the vendored\r\nwriter, which downloads directly and returns `undefined` (and throws on\r\n`download: false`).\r\n\r\n### Excel Table (filter dropdowns + totals row)\r\n\r\nWrap a single-sheet xlsx in a native **Excel Table** - the user gets column\r\nfilter dropdowns, banded rows, structured references, and (optionally) a totals\r\nrow with live `SUM()` formulas:\r\n\r\n```ts\r\nawait api.exportData({ format: 'xlsx', excelTable: { totalsRow: true } })\r\n// or just `excelTable: true` for the table without a totals row\r\n```\r\n\r\nColumn widths auto-fit and leading **pinned columns are frozen automatically**\r\n(override with `freezeColumns`). For long IDs, `precisionSafe: true` writes them\r\nas text so Excel's 15-digit float limit doesn't silently round `1002000300040005`.\r\n\r\n### JSON / XML / Markdown\r\n\r\n```ts\r\nawait api.exportData({ format: 'json' }) // array of { field: value } - defaults to raw values\r\nawait api.exportData({ format: 'xml' }) // <rows><row><field>value</field>…\r\nawait api.exportData({ format: 'md' }) // GitHub-flavored Markdown table (great for docs / LLMs)\r\n```\r\n\r\nJSON defaults to **raw** values (real numbers/dates) since it's a data format;\r\npass `rawValues: false` for formatted strings. XML and Markdown use the\r\nformatted display values.\r\n\r\n### Copy to clipboard\r\n\r\n`api.copyExport(opts)` serializes to the clipboard instead of downloading a\r\nfile - handy for \"copy these rows into a spreadsheet or an email\":\r\n\r\n```ts\r\nawait api.copyExport({ format: 'tsv' }) // pastes into Excel / Sheets as columns (default)\r\nawait api.copyExport({ format: 'md', rows: 'selected' }) // Markdown table of the checked rows\r\nawait api.copyExport({ format: 'html' }) // rich text/html - keeps the table when pasted\r\n```\r\n\r\nSupported formats: `csv`, `tsv`, `html`, `json`, `xml`, `md`. `html` writes\r\nboth `text/html` (rich paste) and a `text/plain` fallback. Needs a secure\r\ncontext and a user gesture (call it from a click).\r\n\r\n### Conditional formatting in exports\r\n\r\nPass the same `conditionalFormats` array you give `<SvGrid>` to carry the grid's\r\ncolor scales, data bars, icon sets, and predicate rules into the **styled**\r\nformats - PDF, xlsx, and HTML (and `print()`):\r\n\r\n```ts\r\nconst conditionalFormats = [\r\n { type: 'colorScale', columns: ['score'], min: '#dcfce7', max: '#fee2e2' },\r\n { type: 'rule', columns: ['status'], when: ({ value }) => value === 'Overdue', background: '#fecaca' },\r\n]\r\n\r\nawait api.exportData({ format: 'pdf', conditionalFormats })\r\nawait api.exportData({ format: 'xlsx', conditionalFormats }) // real cell fills in Excel\r\nawait api.print({ conditionalFormats })\r\n```\r\n\r\nCell background, text color, and bold carry through as **real cell fills** in\r\n`xlsx` (single-sheet), `xls`, `html`, and `pdf`; icon sets prepend their glyph\r\nto the cell text.\r\n\r\nIn **xlsx**, **data bars** and **color scales** become **native Excel\r\nconditional formatting** (`<conditionalFormatting>` rules) - Excel draws and\r\nrecomputes them itself, so they're interactive, not a static snapshot.\r\nPredicate `rule` formats stay as computed per-cell fills. In the other styled\r\nformats (`xls` / `html` / `pdf`) a data bar degrades to a light fill of the bar\r\ncolor (cells can't hold a partial-width bar). The data formats (csv / tsv /\r\njson / xml / md) are unstyled, so conditional formatting is ignored there.\r\nGrouped PDF exports skip conditional formatting (the group/subtotal rows own the\r\nlayout).\r\n\r\n### PDF options\r\n\r\nThe PDF is built from a full pdfmake document you control via `pdf`:\r\n\r\n```ts\r\nawait api.exportData({\r\n format: 'pdf',\r\n filename: 'orders',\r\n pdf: {\r\n pageSize: 'A4', // 'A4' | 'A3' | 'A5' | 'LETTER' | 'LEGAL'\r\n pageOrientation: 'landscape',\r\n title: 'Q3 Orders',\r\n subtitle: 'Generated by Ops',\r\n logo: logoDataUrl, // top-left image (data URL)\r\n headerColor: '#334155', // header row fill\r\n zebra: true, // striped rows\r\n columnWidths: '*', // '*' fill, 'auto', or a per-column pt array\r\n // repeatHeader (default true), showPageNumbers (default true) also available\r\n },\r\n})\r\n```\r\n\r\nThe header row repeats on every page, each page gets a `Page X of Y` +\r\ndate footer, per-column alignment is honored, and wide grids auto-switch to\r\nlandscape (override with `pdf.pageOrientation`).\r\n\r\nWhen the grid is grouped, the PDF carries the grouping: a bold **group header**\r\nper cluster (nested for multi-level grouping) and a **subtotal row** summing the\r\nnumber / currency columns - matching the xlsx outline export. It's on by\r\ndefault (`autoGroup`); pass `groupBy` to override or `autoGroup: false` to get a\r\nflat table.\r\n\r\n#### Merged cells\r\n\r\nPass `merges` to write real merged regions into the sheet. Row / column\r\nindices are zero-based over the exported **body** (the header row is not\r\ncounted), and the shape lines up with the grid's own `MergeSpec`:\r\n\r\n```ts\r\n// Merge the first two data rows of column 0, and span a 3-column banner\r\nawait api.exportData({\r\n format: 'xlsx',\r\n merges: [\r\n { row: 0, col: 0, rowSpan: 2 }, // vertical merge (e.g. a repeated group key)\r\n { row: 5, col: 0, colSpan: 3 }, // horizontal merge (a section banner)\r\n ],\r\n})\r\n```\r\n\r\nTo export the merges you already show in the grid, take your `MergeSpec[]`\r\n(`{ rowIndex, columnId, rowspan, colspan }`) and map `columnId` to its column\r\nindex: `{ row: m.rowIndex, col: colIndex(m.columnId), rowSpan: m.rowspan, colSpan: m.colspan }`.\r\n\r\nThrows on missing peer (`jszip` / `pdfmake`), revoked / malformed\r\nlicense, or empty result set. With no license set, it runs but the\r\ngrid is watermarked.\r\n\r\n### `api.print(opts?)` - `Promise<void>`\r\n\r\nOpens a new window with a paginated, printable rendering of the grid and\r\ntriggers the browser print dialog. This is the **zero-dependency \"Save as PDF\"**\r\nroute: it uses the browser's own engine, so it has excellent font + CSS\r\nfidelity (including CJK / RTL) and needs no `pdfmake`. The header row repeats on\r\nevery page, values print **formatted** (matching the grid), and columns keep\r\ntheir alignment.\r\n\r\n| Option | Type | Default |\r\n| ------------- | -------------------------------------- | ----------- |\r\n| `title` | `string` | `\"Grid\"` |\r\n| `subtitle` | `string` | - |\r\n| `logo` | `string` (data URL) | - |\r\n| `columns` | `{ field; header?; format?; align? }[]`| grid's own columns |\r\n| `rows` | `ReadonlyArray<TData> \\| 'displayed' \\| 'selected' \\| 'all'` | `'displayed'` |\r\n| `rawValues` | `boolean` | `false` |\r\n| `orientation` | `'portrait' \\| 'landscape'` | `\"portrait\"` |\r\n| `pageSize` | `string` (e.g. `'A4'`, `'Letter'`) | browser default |\r\n| `margin` | `string` (e.g. `'14mm'`) | `'14mm'` |\r\n| `zebra` | `boolean` | `true` |\r\n| `headerColor` | `string` | `'#f1f5f9'` |\r\n\r\nBrowsers may block the popup unless `print()` is called from a user\r\ngesture (a click handler is fine - automatic on-load print is not). Page\r\nnumbers come from the browser's own print header/footer.\r\n\r\n**`exportData({ format: 'pdf' })` vs `print()`** - use PDF export for a\r\nprogrammatic, silent file download with full layout control (see \"PDF\r\noptions\"); use `print()` when you want best-fidelity output and the user to\r\npick print or \"Save as PDF\" from the dialog.\r\n\r\n## Drop-in Export menu (`SvExportMenu`)\r\n\r\nRather than wiring your own toolbar, drop in the bundled `SvExportMenu`\r\ncomponent. It renders an \"Export\" button with a format picker, a row-scope\r\ntoggle (current view / selected / all), and a built-in progress bar + Cancel\r\nfor large exports:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, type SvGridApi } from '@svgrid/grid'\r\n import { installEnterprise, SvExportMenu, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n</script>\r\n\r\n<div class=\"toolbar\">\r\n <SvExportMenu {api} filename=\"orders\" />\r\n</div>\r\n\r\n<SvGrid data={rows} columns={columns} features={features}\r\n onApiReady={(a) => (api = installEnterprise(a))} />\r\n```\r\n\r\n| Prop | Type | Default | Notes |\r\n| ------------ | ------------------------------------------------ | --------------------------- | ----- |\r\n| `api` | `EnterpriseGridApi \\| null` | required | Button is disabled until the api is ready. |\r\n| `filename` | `string` | `'grid'` | Base name, no extension. |\r\n| `formats` | `ExportFormat[]` | xlsx/xls/pdf/csv/tsv/html | Which formats to offer, in order. |\r\n| `allowScope` | `boolean` | `true` | Show the current-view / selected / all toggle. |\r\n| `allowColumns` | `boolean` | `true` | Show per-column include checkboxes (choose which columns to export). |\r\n| `allowCopy` | `boolean` | `true` | Show \"Copy for Excel\" (tsv) + \"Copy Markdown\" clipboard actions. |\r\n| `allowPrint` | `boolean` | `false` | Show a \"Print…\" action (`api.print`). |\r\n| `scope` | `'displayed' \\| 'selected' \\| 'all'` | `'displayed'` | Initial row scope. |\r\n| `label` | `string` | `'Export'` | Button text. |\r\n| `conditionalFormats` | `ConditionalFormat[]` | - | Carried into pdf / xlsx / html exports. |\r\n\r\nThe menu is sectioned into **Rows** (scope), **Columns** (a picker), and\r\n**Download** (each format shown with an icon + one-line description), plus\r\n**copy-to-clipboard** and optional **print** actions. It's **keyboard\r\naccessible** (arrow keys move between items, Tab is trapped inside, Esc closes\r\nand returns focus to the button, with `role=\"menu\"` / `menuitem`), handles\r\nerrors inline (missing peer dependency, empty grid), and wires `onProgress` +\r\nan `AbortController` for you.\r\n\r\nFor large exports, the single-sheet xlsx writer builds in **chunks that yield to\r\nthe event loop** (and zips asynchronously), so the tab stays responsive and\r\n`onProgress` ticks; pass a `signal` to cancel.\r\n\r\n## Gotchas\r\n\r\n- **Empty grids** - both `exportData` and `print` throw if there are no\r\n displayed rows. Catch the error and show a notice in the UI.\r\n- **Column ordering** - if you don't pass `columns`, the export uses\r\n `Object.keys(rows[0])` order. Pass `columns` explicitly when the row\r\n shape doesn't match the column order you want.\r\n- **Cell formatters** - column-level format hints (date, number, currency,\r\n percent) are applied to the exported value by default, so the file matches\r\n the grid. Custom snippet renderers are **not** auto-serialized - give those\r\n columns an `exportValue` hook (see \"Faithful values\" above), or set\r\n `rawValues: true` if you want the raw underlying values.\r\n- **Print popup blocked** - `print()` resolves but the browser silently\r\n blocks the new window. Always trigger from a user click, and surface\r\n the thrown error.\r\n- **Bundle size** - the vendored exporter is ~50 KB minified. It is\r\n loaded lazily on first call so it does not bloat the initial bundle\r\n for users who never export.\r\n\r\n## Frequently asked questions\r\n\r\n### How do I export a Svelte data grid to Excel?\r\n\r\nInstall `@svgrid/enterprise`, call `installEnterprise(api)`, then call the export helper with\r\n`format: 'xlsx'`. The exporter writes a real OOXML workbook in the browser - no\r\nserver round-trip. CSV, TSV, PDF, and HTML use the same call with a different\r\nformat.\r\n\r\n### What's the difference between `xls` and `xlsx`?\r\n\r\n`xlsx` is the modern OOXML workbook (Excel 2007+); it needs the `jszip` peer\r\ndependency and supports the richest output (styles, images, multi-sheet,\r\nformulas). `xls` is the **legacy Excel 2003 XML Spreadsheet** format: pick it\r\nfor maximum compatibility with old Excel installs or systems that only accept\r\n`.xls`. It has **no peer dependency**, streams in the browser, and keeps\r\nnumbers and dates typed (so sums and sorting still work in Excel) with number\r\nformats applied. It's single-sheet - use `xlsx` for multi-tab workbooks.\r\n\r\n```ts\r\nawait api.exportData({ format: 'xls', filename: 'orders' })\r\n```\r\n\r\n### Is export part of the free Community package?\r\n\r\nNo. Export and printing ship in the paid `@svgrid/enterprise` add-on. The free\r\n`@svgrid/grid` package covers the full grid (sorting, filtering, grouping,\r\nediting, virtualization) but not export/print/pivot/import.\r\n\r\n### Does exporting bloat my bundle?\r\n\r\nNo. The ~50 KB exporter is lazy-loaded on the first export call, so users who\r\nnever export never download it.\r\n"
3303
3324
  },
3304
3325
  {
3305
3326
  "slug": "help/expressions-query",
@@ -3353,7 +3374,7 @@ export const docs = [
3353
3374
  "slug": "help/filtering/overview",
3354
3375
  "path": "docs/help/filtering/overview.md",
3355
3376
  "title": "Filtering - overview",
3356
- "markdown": "# Filtering - overview\n\nClick any column header's filter icon to open the operator + value\npopover; numeric and date columns range-bucket their distinct values\nso the menu stays usable on big datasets:\n\n![Per-column filters and the quick filter collapse into one filterModel that the engine or your ServerDataSource applies to produce the filtered rows.](/docs-media/grid-filter-model.svg)\n\n<div data-docs-demo=\"03-excel-filters\" data-height=\"460\"></div>\n\nSvGrid offers four filtering surfaces. You opt into the one(s) you need\nthrough the `filterMode` prop on `<SvGrid>`:\n\n| `filterMode` | What it shows |\n| ------------ | ------------- |\n| `'menu'` (default) | A \"filter icon\" in each header opens a per-column operator + value popover. |\n| `'row'` | A filter row under the header - one input per column. |\n| `'global'` | A single search box above the grid that searches all visible columns. |\n| `'none'` | No filter UI. Drive filters programmatically only. |\n\n```svelte\n<SvGrid {data} {columns} features={features} filterMode=\"row\" />\n```\n\nPer-surface props (`showColumnFilters`, `showFilterRow`, `showGlobalFilter`)\noverride `filterMode` when set explicitly - useful when you want two\nsurfaces simultaneously.\n\n## Feature registration\n\nFiltering is gated by `columnFilteringFeature` plus\n`createFilteredRowModel`. Both must be registered for the column filter UI\nto actually filter rows:\n\n```ts\nimport {\n tableFeatures, columnFilteringFeature, createFilteredRowModel,\n} from '@svgrid/grid'\n\nconst features = tableFeatures({ columnFilteringFeature })\n```\n\nThe wrapper auto-registers `createFilteredRowModel` when the feature is\npresent.\n\n## Operators\n\nAll built-in operators:\n\n| Operator | Applies to | Behaviour |\n| ------------- | ---------- | --------- |\n| `contains` | text | case-insensitive substring |\n| `equals` | text, num, date, bool | strict equality (numeric where possible) |\n| `startsWith` | text | case-insensitive prefix |\n| `greaterThan` | num, date | strict `>` |\n| `lessThan` | num, date | strict `<` |\n| `between` | num, date | inclusive range - requires `valueTo` |\n| `isBlank` | any | empty / null / undefined / whitespace |\n\nThe set of operators offered per column depends on `editorType`:\n\n| `editorType` | operators |\n| ------------ | --------- |\n| `'text'` (default) | contains, equals, startsWith, isBlank |\n| `'number'` | equals, greaterThan, lessThan, isBlank |\n| `'date'` / `'datetime'` | equals, lessThan, greaterThan, isBlank |\n| `'checkbox'` | equals, isBlank |\n\n## Built-in `filterFns`\n\nFor programmatic filtering (without the menu), pass a `filterFn` on the\ncolumn or use the headless `createFilteredRowModel` directly.\n\n```ts\nimport { filterFns } from '@svgrid/grid'\n\nfilterFns.includesString(cellValue, query)\nfilterFns.equals(cellValue, query)\n```\n\n## See also\n\n- [Text filter](./text-filter.md)\n- [Number filter](./number-filter.md)\n- [Date filter](./date-filter.md)\n- [Set filter](./set-filter.md)\n- [Filter API](./filter-api.md)\n- [demos/03-excel-filters.svelte](../../../examples/src/demos/03-excel-filters.svelte)\n\n## Frequently asked questions\n\n### How do I filter a column in SvGrid?\n\nClick a column header's filter icon to open the operator + value popover. Text\ncolumns get `contains` / `equals` / `startsWith` / `isBlank`; number and date\ncolumns add `greaterThan` / `lessThan` / `between`. Filtering is on by default\nonce the filtering feature is registered.\n\n### Does SvGrid have Excel-style set filters?\n\nYes. A set filter shows a checklist of a column's distinct values so users can\ninclude \"active OR pending\" with checkboxes. Numeric and date columns\nrange-bucket their values so the list stays usable on large datasets.\n\n### Can I filter on the server?\n\nYes. Set `externalFilter` so the grid records filter state but your API does the\nfiltering. The grid emits the consolidated filter payload via `onFiltersChange`\nfor you to forward to the server.\n"
3377
+ "markdown": "# Filtering - overview\n\nClick any column header's filter icon to open the operator + value\npopover; numeric and date columns range-bucket their distinct values\nso the menu stays usable on big datasets:\n\n![Per-column filters and the quick filter collapse into one filterModel that the engine or your ServerDataSource applies to produce the filtered rows.](/docs-media/grid-filter-model.svg)\n\n<div data-docs-demo=\"03-excel-filters\" data-height=\"460\"></div>\n\nSvGrid offers four filtering surfaces. You opt into the one(s) you need\nthrough the `filterMode` prop on `<SvGrid>`:\n\n| `filterMode` | What it shows |\n| ------------ | ------------- |\n| `'menu'` (default) | A \"filter icon\" in each header opens a per-column operator + value popover. |\n| `'row'` | A filter row under the header - one input per column. |\n| `'global'` | A single search box above the grid that searches all visible columns. |\n| `'none'` | No filter UI. Drive filters programmatically only. |\n\n```svelte\n<SvGrid {data} {columns} features={features} filterMode=\"row\" />\n```\n\nPer-surface props (`showColumnFilters`, `showFilterRow`, `showGlobalFilter`)\noverride `filterMode` when set explicitly - useful when you want two\nsurfaces simultaneously.\n\n## Feature registration\n\nFiltering is gated by `columnFilteringFeature` plus\n`createFilteredRowModel`. Both must be registered for the column filter UI\nto actually filter rows:\n\n```ts\nimport {\n tableFeatures, columnFilteringFeature, createFilteredRowModel,\n} from '@svgrid/grid'\n\nconst features = tableFeatures({ columnFilteringFeature })\n```\n\nThe wrapper auto-registers `createFilteredRowModel` when the feature is\npresent.\n\n## Operators\n\nAll built-in operators:\n\n| Operator | Applies to | Behaviour |\n| ------------- | ---------- | --------- |\n| `contains` | text | case-insensitive substring |\n| `equals` | text, num, date, bool | strict equality (numeric where possible) |\n| `startsWith` | text | case-insensitive prefix |\n| `greaterThan` | num, date | strict `>` |\n| `lessThan` | num, date | strict `<` |\n| `between` | num, date | inclusive range - requires `valueTo` |\n| `isBlank` | any | empty / null / undefined / whitespace |\n\nThe set of operators offered per column depends on `editorType`:\n\n| `editorType` | operators |\n| ------------ | --------- |\n| `'text'` (default) | contains, equals, startsWith, isBlank |\n| `'number'` | equals, greaterThan, lessThan, isBlank |\n| `'date'` / `'datetime'` | equals, lessThan, greaterThan, isBlank |\n| `'checkbox'` | equals, isBlank |\n\n## Built-in `filterFns`\n\nFor programmatic filtering (without the menu), pass a `filterFn` on the\ncolumn or use the headless `createFilteredRowModel` directly.\n\n```ts\nimport { filterFns } from '@svgrid/grid'\n\nfilterFns.includesString(cellValue, query)\nfilterFns.equals(cellValue, query)\n```\n\n## See also\n\n- [Text filter](./text-filter.md)\n- [Number filter](./number-filter.md)\n- [Date filter](./date-filter.md)\n- [Set filter](./set-filter.md)\n- [Filter API](./filter-api.md)\n- [demos/03-excel-filters.svelte](../../../examples/src/demos/03-excel-filters.svelte)\n\n## Frequently asked questions\n\n### How do I filter a column in SvGrid?\n\nClick a column header's filter icon to open the operator + value popover. Text\ncolumns get `contains` / `equals` / `startsWith` / `isBlank`; number and date\ncolumns add `greaterThan` / `lessThan` / `between`. Filtering is on by default\nonce the filtering feature is registered.\n\n### Does SvGrid have Excel-style set filters?\n\nYes. A set filter shows a checklist of a column's distinct values so users can\ninclude \"active OR pending\" with checkboxes. Numeric and date columns\nrange-bucket their values so the list stays usable on large datasets.\n\n### Can I filter on the server?\n\nYes. Set `externalFilter` so the grid records filter state but your API does the\nfiltering. The grid emits the consolidated filter payload via `onFiltersChange`\nfor you to forward to the server.\n"
3357
3378
  },
3358
3379
  {
3359
3380
  "slug": "help/filtering/set-filter",
@@ -3377,13 +3398,13 @@ export const docs = [
3377
3398
  "slug": "help/grouping-aggregation",
3378
3399
  "path": "docs/help/grouping-aggregation.md",
3379
3400
  "title": "Grouping & aggregation",
3380
- "markdown": "# Grouping & aggregation\r\n\r\nRoll rows up by one or more columns and compute aggregates (sum, avg,\r\ncount, min, max, custom) at each group level. Powered by\r\n`columnGroupingFeature` plus per-column `aggregator` config.\r\n\r\n![Flat rows are grouped by one or more fields, aggregated with sum, average or count, then optionally pivoted across rows and columns.](/docs-media/grid-grouping-pivot.svg)\r\n\r\nTry it: drag a column into the group-by lane, then change aggregators\r\nper column:\r\n\r\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"500\"></div>\r\n\r\n## Minimal example\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid, tableFeatures, rowSortingFeature, columnGroupingFeature,\r\n type ColumnDef,\r\n } from '@svgrid/grid'\r\n\r\n type Employee = {\r\n id: number; name: string; department: string;\r\n salary: number; performance: number\r\n }\r\n\r\n const features = tableFeatures({ rowSortingFeature, columnGroupingFeature })\r\n\r\n const columns: ColumnDef<typeof features, Employee>[] = [\r\n { field: 'department', header: 'Department' },\r\n { field: 'name', header: 'Name' },\r\n { field: 'salary', header: 'Salary',\r\n aggregate: 'sum',\r\n format: { type: 'currency', currency: 'USD' } },\r\n { field: 'performance', header: 'Performance',\r\n aggregate: 'avg' },\r\n ]\r\n\r\n const rows: Employee[] = [\r\n { id: 1, name: 'Ada', department: 'Engineering', salary: 180_000, performance: 4.8 },\r\n { id: 2, name: 'Linus', department: 'Engineering', salary: 195_000, performance: 4.6 },\r\n { id: 3, name: 'Grace', department: 'Operations', salary: 165_000, performance: 4.9 },\r\n ]\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n groupBy={['department']}\r\n/>\r\n```\r\n\r\nThe grid emits one group row per unique department value, with the\r\ngroup cell showing the rolled-up salary sum + average performance.\r\nClick the chevron on a group row to expand its children.\r\n\r\n## Setting the group-by\r\n\r\nThree ways, ranked by ergonomic order:\r\n\r\n1. **The column menu.** When the user opens a header's menu, \"Group\r\n by this column\" toggles that column in/out of the group-by list.\r\n2. **The `groupBy` prop.** Initial state for the group-by list.\r\n3. **The imperative API.** `api.setGroupBy(['department', 'role'])`\r\n for toolbars / saved views.\r\n\r\nThe group order is significant - `['region', 'country']` rolls up\r\ncountry inside region; reverse the array to flip the hierarchy.\r\n\r\n## Built-in aggregators\r\n\r\n| Aggregator | Returns | Behaviour on empty groups |\r\n| ---------- | ---------------------------------------------------- | ------------------------- |\r\n| `'sum'` | Sum of numeric cell values | `0` |\r\n| `'avg'` | Arithmetic mean (with safe divide-by-zero) | `null` |\r\n| `'count'` | Number of leaf rows | `0` |\r\n| `'min'` | Smallest value (numeric or `Intl.Collator`-comparable) | `null` |\r\n| `'max'` | Largest value | `null` |\r\n\r\n`'sum'` / `'avg'` / `'min'` / `'max'` cast values to `Number`. If the\r\ncolumn has non-numeric values mixed in, those rows are skipped.\r\n\r\n## Custom aggregator\r\n\r\nPass a function instead of a string for any group-aware computation:\r\n\r\n```ts\r\n{\r\n field: 'orders',\r\n header: 'Top customer',\r\n aggregate: (rows) => {\r\n const top = rows.reduce<Employee | null>(\r\n (acc, r) => !acc || r.orders > acc.orders ? r : acc,\r\n null,\r\n )\r\n return top?.name ?? '-'\r\n },\r\n}\r\n```\r\n\r\nThe callback gets every leaf row in the group (already filtered).\r\nReturn whatever the cell should display - string, number, or a\r\nformatted value.\r\n\r\n## Custom group cell rendering\r\n\r\nBy default the group cell shows `key (n)` - e.g. \"Engineering (12)\".\r\nOverride via the column's `cell` template:\r\n\r\n```svelte\r\n{#snippet GroupCell(props: { row: GroupRow<Employee> })}\r\n <span class=\"font-semibold\">\r\n {props.row.groupKey}\r\n <span class=\"text-sm opacity-60\">({props.row.subRows.length} reports)</span>\r\n </span>\r\n{/snippet}\r\n```\r\n\r\nThe `row.groupKey` is the unique group value (the department name in\r\nthe example). `row.subRows` is the children. `row.depth` is the\r\nnesting level (useful for indentation when you group by multiple\r\ncolumns).\r\n\r\n## Aggregating string columns\r\n\r\nStrings work with `'count'`, `'min'`, `'max'`, and any custom\r\naggregator. For sum / avg you'll get `NaN` because the cast to\r\n`Number` fails - the grid renders this as `-` by default.\r\n\r\nA useful custom aggregator for strings:\r\n\r\n```ts\r\n{\r\n field: 'tags',\r\n aggregate: (rows) => {\r\n const set = new Set<string>()\r\n for (const r of rows) for (const t of r.tags) set.add(t)\r\n return Array.from(set).join(', ')\r\n },\r\n}\r\n```\r\n\r\n## Performance\r\n\r\nAggregation runs once per group-by change, NOT per scroll frame. The\r\ncost is O(n) for `count` / `sum` / `avg`, O(n log n) for `min` / `max`\r\nbecause the engine sorts to find the extreme.\r\n\r\nFor a 100k-row dataset grouped by two columns with three aggregators,\r\nthe pipeline adds ~36 ms to the initial paint (see\r\n[Performance benchmarks](./benchmarks.md)). After that, scroll is\r\nunaffected - the renderer hands each visible group its precomputed\r\nvalue.\r\n\r\n## Group expansion state\r\n\r\n`expanded` is owned by the engine by default; you can hoist it for\r\nsaved-views purposes:\r\n\r\n```svelte\r\n<SvGrid\r\n ...\r\n expanded={controlledExpanded}\r\n onExpandedChange={(next) => (controlledExpanded = next)}\r\n/>\r\n```\r\n\r\nThe shape is `Record<groupId, boolean>` where `groupId` is the path\r\nthrough the hierarchy (`'Engineering > Senior'`).\r\n\r\n## Group sort vs leaf sort\r\n\r\nThe sort UI sorts within the active sort scope:\r\n\r\n- When grouping is OFF, sort applies to all rows.\r\n- When grouping is ON, sort applies WITHIN each group - groups stay\r\n in alphabetical (or group-aggregator) order; only the leaves inside\r\n each group reorder.\r\n\r\nTo sort the groups themselves by their rolled-up value, set the sort\r\non the aggregated column AFTER setting the group-by. The grid\r\nrecognises that the column is aggregated and sorts the group rows\r\ninstead of the leaves.\r\n\r\n## Filtering vs grouping\r\n\r\nFilters run BEFORE grouping (see [Architecture](./architecture.md) for\r\nthe pipeline order). The aggregator only sees rows that passed the\r\nfilter. This is what makes \"department salary sum, filtered to active\r\nemployees only\" work without any extra config.\r\n\r\n## Pivot vs group-by\r\n\r\nWhen the question is \"group by row dimensions, also group by column\r\ndimensions, also pick aggregators per measure\" - that's a pivot. The\r\n[pivot helpers](./pivot.md) build a different data structure\r\noptimised for that shape. Use group-by when you only roll up rows;\r\nuse pivot when you also roll up columns.\r\n\r\n## See also\r\n\r\n- [Architecture overview](./architecture.md) - where grouping sits in\r\n the pipeline.\r\n- [Pivot tables](./pivot.md) - the column-axis version.\r\n- [Row pagination](./rows/row-pagination.md) - the paging stage runs\r\n AFTER grouping, so group rows count toward the page size.\r\n- [Demo #07 Grouping + aggregation](https://svgrid.com/demos/07-grouping-aggregation/)\r\n - the source for the example above.\r\n\r\n## Frequently asked questions\r\n\r\n### How do I group rows in SvGrid?\r\n\r\nRegister `columnGroupingFeature` and group by one or more columns. Each group\r\nrenders a collapsible header row, and you attach an `aggregator` per column to\r\ncompute sum, avg, count, min, max, or a custom reducer at every group level.\r\n\r\n### What aggregation functions does SvGrid support?\r\n\r\nBuilt-in `sum`, `avg`, `count`, `min`, and `max`, plus custom aggregators -\r\nany function that reduces a group's rows to a single value. Aggregates compute\r\nat each group level and at the grand-total footer.\r\n\r\n### Is grouping the same as a pivot table?\r\n\r\nNo. Grouping rolls rows up along the row axis. A pivot table also spreads a\r\nfield across the column axis with nested headers - that is the `@svgrid/enterprise`\r\npivot model. See [Pivot tables](./pivot.md) for the column-axis version.\r\n"
3401
+ "markdown": "# Grouping & aggregation\r\n\r\nRoll rows up by one or more columns and compute aggregates (sum, avg,\r\ncount, min, max, custom) at each group level. Powered by\r\n`columnGroupingFeature` plus per-column `aggregator` config.\r\n\r\n![Flat rows are grouped by one or more fields, aggregated with sum, average or count, then optionally pivoted across rows and columns.](/docs-media/grid-grouping-pivot.svg)\r\n\r\nTry it: drag a column into the group-by lane, then change aggregators\r\nper column:\r\n\r\n<div data-docs-demo=\"07-grouping-aggregation\" data-height=\"500\"></div>\r\n\r\n## Minimal example\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid, tableFeatures, rowSortingFeature, columnGroupingFeature,\r\n type ColumnDef,\r\n } from '@svgrid/grid'\r\n\r\n type Employee = {\r\n id: number; name: string; department: string;\r\n salary: number; performance: number\r\n }\r\n\r\n const features = tableFeatures({ rowSortingFeature, columnGroupingFeature })\r\n\r\n const columns: ColumnDef<typeof features, Employee>[] = [\r\n { field: 'department', header: 'Department' },\r\n { field: 'name', header: 'Name' },\r\n { field: 'salary', header: 'Salary',\r\n aggregate: 'sum',\r\n format: { type: 'currency', currency: 'USD' } },\r\n { field: 'performance', header: 'Performance',\r\n aggregate: 'avg' },\r\n ]\r\n\r\n const rows: Employee[] = [\r\n { id: 1, name: 'Ada', department: 'Engineering', salary: 180_000, performance: 4.8 },\r\n { id: 2, name: 'Linus', department: 'Engineering', salary: 195_000, performance: 4.6 },\r\n { id: 3, name: 'Grace', department: 'Operations', salary: 165_000, performance: 4.9 },\r\n ]\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n groupBy={['department']}\r\n/>\r\n```\r\n\r\nThe grid emits one group row per unique department value, with the\r\ngroup cell showing the rolled-up salary sum + average performance.\r\nClick the chevron on a group row to expand its children.\r\n\r\n## Setting the group-by\r\n\r\nThree ways, ranked by ergonomic order:\r\n\r\n1. **The column menu.** When the user opens a header's menu, \"Group\r\n by this column\" toggles that column in/out of the group-by list.\r\n2. **The `groupBy` prop.** Initial state for the group-by list.\r\n3. **The imperative API.** `api.setGroupBy(['department', 'role'])`\r\n for toolbars / saved views.\r\n\r\nThe group order is significant - `['region', 'country']` rolls up\r\ncountry inside region; reverse the array to flip the hierarchy.\r\n\r\n## Built-in aggregators\r\n\r\n| Aggregator | Returns | Behaviour on empty groups |\r\n| ---------- | ---------------------------------------------------- | ------------------------- |\r\n| `'sum'` | Sum of numeric cell values | `0` |\r\n| `'avg'` | Arithmetic mean (with safe divide-by-zero) | `null` |\r\n| `'count'` | Number of leaf rows | `0` |\r\n| `'min'` | Smallest value (numeric or `Intl.Collator`-comparable) | `null` |\r\n| `'max'` | Largest value | `null` |\r\n\r\n`'sum'` / `'avg'` / `'min'` / `'max'` cast values to `Number`. If the\r\ncolumn has non-numeric values mixed in, those rows are skipped.\r\n\r\n## Custom aggregator\r\n\r\nPass a function instead of a string for any group-aware computation:\r\n\r\n```ts\r\n{\r\n field: 'orders',\r\n header: 'Top customer',\r\n aggregate: (rows) => {\r\n const top = rows.reduce<Employee | null>(\r\n (acc, r) => !acc || r.orders > acc.orders ? r : acc,\r\n null,\r\n )\r\n return top?.name ?? '-'\r\n },\r\n}\r\n```\r\n\r\nThe callback gets every leaf row in the group (already filtered).\r\nReturn whatever the cell should display - string, number, or a\r\nformatted value.\r\n\r\n## Custom group cell rendering\r\n\r\nBy default the group cell shows `key (n)` - e.g. \"Engineering (12)\".\r\nOverride via the column's `cell` template:\r\n\r\n```svelte\r\n{#snippet GroupCell(props: { row: GroupRow<Employee> })}\r\n <span class=\"font-semibold\">\r\n {props.row.groupKey}\r\n <span class=\"text-sm opacity-60\">({props.row.subRows.length} reports)</span>\r\n </span>\r\n{/snippet}\r\n```\r\n\r\nThe `row.groupKey` is the unique group value (the department name in\r\nthe example). `row.subRows` is the children. `row.depth` is the\r\nnesting level (useful for indentation when you group by multiple\r\ncolumns).\r\n\r\n## Aggregating string columns\r\n\r\nStrings work with `'count'`, `'min'`, `'max'`, and any custom\r\naggregator. For sum / avg you'll get `NaN` because the cast to\r\n`Number` fails - the grid renders this as `-` by default.\r\n\r\nA useful custom aggregator for strings:\r\n\r\n```ts\r\n{\r\n field: 'tags',\r\n aggregate: (rows) => {\r\n const set = new Set<string>()\r\n for (const r of rows) for (const t of r.tags) set.add(t)\r\n return Array.from(set).join(', ')\r\n },\r\n}\r\n```\r\n\r\n## Performance\r\n\r\nAggregation runs once per group-by change, NOT per scroll frame. The\r\ncost is O(n) for `count` / `sum` / `avg`, O(n log n) for `min` / `max`\r\nbecause the engine sorts to find the extreme.\r\n\r\nFor a 100k-row dataset grouped by two columns with three aggregators,\r\nthe pipeline adds ~36 ms to the initial paint (see\r\n[Performance benchmarks](./benchmarks.md)). After that, scroll is\r\nunaffected - the renderer hands each visible group its precomputed\r\nvalue.\r\n\r\n## Group expansion state\r\n\r\n`expanded` is owned by the engine by default; you can hoist it for\r\nsaved-views purposes:\r\n\r\n```svelte\r\n<SvGrid\r\n ...\r\n expanded={controlledExpanded}\r\n onExpandedChange={(next) => (controlledExpanded = next)}\r\n/>\r\n```\r\n\r\nThe shape is `Record<groupId, boolean>` where `groupId` is the path\r\nthrough the hierarchy (`'Engineering > Senior'`).\r\n\r\n## Group sort vs leaf sort\r\n\r\nThe sort UI sorts within the active sort scope:\r\n\r\n- When grouping is OFF, sort applies to all rows.\r\n- When grouping is ON, sort applies WITHIN each group - groups stay\r\n in alphabetical (or group-aggregator) order; only the leaves inside\r\n each group reorder.\r\n\r\nTo sort the groups themselves by their rolled-up value, set the sort\r\non the aggregated column AFTER setting the group-by. The grid\r\nrecognises that the column is aggregated and sorts the group rows\r\ninstead of the leaves.\r\n\r\n## Filtering vs grouping\r\n\r\nFilters run BEFORE grouping (see [Architecture](./architecture.md) for\r\nthe pipeline order). The aggregator only sees rows that passed the\r\nfilter. This is what makes \"department salary sum, filtered to active\r\nemployees only\" work without any extra config.\r\n\r\n## Pivot vs group-by\r\n\r\nWhen the question is \"group by row dimensions, also group by column\r\ndimensions, also pick aggregators per measure\" - that's a pivot. The\r\n[pivot helpers](./pivot.md) build a different data structure\r\noptimised for that shape. Use group-by when you only roll up rows;\r\nuse pivot when you also roll up columns.\r\n\r\n## See also\r\n\r\n- [Architecture overview](./architecture.md) - where grouping sits in\r\n the pipeline.\r\n- [Pivot tables](./pivot.md) - the column-axis version.\r\n- [Row pagination](./rows/row-pagination.md) - the paging stage runs\r\n AFTER grouping, so group rows count toward the page size.\r\n- [Demo #07 Grouping + aggregation](https://svgrid.com/demos/07-grouping-aggregation/)\r\n - the source for the example above.\r\n\r\n## Display modes\r\n\r\n`groupDisplayMode` decides where group state is drawn:\r\n\r\n| Mode | Result |\r\n| --- | --- |\r\n| `groupRows` (default) | A full-width banner row per group. Unchanged behaviour. |\r\n| `singleColumn` | One synthetic **Group** column holding every level, indented by depth. |\r\n| `multipleColumns` | One synthetic column per grouped field. |\r\n\r\n```svelte\r\n<SvGrid {data} {columns} groupable groupDisplayMode=\"singleColumn\" />\r\n```\r\n\r\nBoth column modes hide the grouped **source** columns, because their values\r\nmove into the auto column - showing both would just duplicate them. They also\r\nrender the group row as an ordinary row, which is the real reason to use them:\r\nits aggregate cells then line up under the columns they belong to instead of\r\nsitting in a full-width strip.\r\n\r\nTune the combined column with `autoGroupColumnHeader` (default `\"Group\"`) and\r\n`autoGroupColumnWidth` (default `220`). In `multipleColumns` each column takes\r\nits name from the source column's header.\r\n\r\n## Group footers (subtotal rows)\r\n\r\n`groupFooters` closes each group with a subtotal row:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} groupable groupFooters />\r\n```\r\n\r\nThe footer is a clone of the group banner, so it already carries that group's\r\naggregates and renders through the normal cell path - each total lands under\r\nits own column instead of in a full-width strip. It is not expandable and has\r\nno expander.\r\n\r\nOnly columns with an `aggregate` produce a value, the same ones that populate\r\nthe banner.\r\n\r\n## Grand total row\r\n\r\n`grandTotalRow` appends a single totals row for the whole filtered set:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} grandTotalRow />\r\n```\r\n\r\nIt is independent of `groupFooters` - use it on a flat grid for a bottom totals\r\nline, or together for subtotals *and* a total:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} groupable groupFooters grandTotalRow />\r\n```\r\n\r\nThree things to know:\r\n\r\n- It aggregates the **leaf** rows, so turning grouping on does not double-count\r\n (the group banners already carry subtotals).\r\n- It follows the **filtered** set, not the raw data - filter the grid and the\r\n total moves with it.\r\n- With `pageable`, it is appended only on the **last** page, so a total never\r\n appears mid-dataset. The value still covers every row, not just that page.\r\n\r\nColumns without an `aggregate` render blank, and if no column declares one the\r\nrow is skipped entirely.\r\n\r\n## Grouping with pagination\r\n\r\n`pageSize` budgets **data** rows. Group banners and footers do not count\r\nagainst it:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} groupable groupFooters pageable pageSize={10} />\r\n```\r\n\r\nA page holds `pageSize` real rows and reprints the banners those rows sit\r\nunder, so a group split across a page boundary is labelled on both pages - the\r\nway a spreadsheet repeats group headers across a page break. Footers are\r\ninserted after paging, so switching them on never pushes data onto the next\r\npage.\r\n\r\nA collapsed group is the visible unit and takes one page slot itself; an\r\nexpanded one is a header and takes none.\r\n\r\n## Frequently asked questions\r\n\r\n### How do I group rows in SvGrid?\r\n\r\nRegister `columnGroupingFeature` and group by one or more columns. Each group\r\nrenders a collapsible header row, and you attach an `aggregator` per column to\r\ncompute sum, avg, count, min, max, or a custom reducer at every group level.\r\n\r\n### What aggregation functions does SvGrid support?\r\n\r\nBuilt-in `sum`, `avg`, `count`, `min`, and `max`, plus custom aggregators -\r\nany function that reduces a group's rows to a single value. Aggregates compute\r\nat each group level and at the grand-total footer.\r\n\r\n### Is grouping the same as a pivot table?\r\n\r\nNo. Grouping rolls rows up along the row axis. A pivot table also spreads a\r\nfield across the column axis with nested headers - that is the `@svgrid/enterprise`\r\npivot model. See [Pivot tables](./pivot.md) for the column-axis version.\r\n"
3381
3402
  },
3382
3403
  {
3383
3404
  "slug": "help/grouping/aggregators",
3384
3405
  "path": "docs/help/grouping/aggregators.md",
3385
3406
  "title": "Group aggregators",
3386
- "markdown": "# Group aggregators\n\nWhen grouping is active, each group row can show a rolled-up value per column -\nthe sum of revenue, the average score, the count of deals. SvGrid does this\ndeclaratively: set `aggregate` on a column and the group header shows the\nresult, formatted with that column's own `format`.\n\n<div data-docs-demo=\"142-group-aggregators\" data-height=\"480\"></div>\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, columnGroupingFeature, type ColumnDef } from '@svgrid/grid'\n\n const features = tableFeatures({ columnGroupingFeature })\n\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'region', header: 'Region' },\n { field: 'revenue', header: 'Revenue', aggregate: 'sum', format: { type: 'currency', currency: 'USD' } },\n { field: 'winRate', header: 'Win rate', aggregate: 'avg', format: { type: 'percent' } },\n ]\n</script>\n\n<SvGrid {data} {columns} {features} groupable />\n```\n\n## Built-in reducers\n\n| `aggregate` | Result |\n| ----------------- | --------------------------------------------------- |\n| `'sum'` | Sum of the finite numeric values. |\n| `'avg'` | Mean of the finite numeric values. |\n| `'min'` / `'max'` | Smallest / largest value. |\n| `'count'` | Number of leaf rows in the group. |\n| `'countDistinct'` | Number of distinct values. |\n| `'extent'` | `\"min – max\"` range string. |\n| `'first'` | The first leaf row's value (e.g. a shared label). |\n\nNon-numeric and empty cells are ignored by the numeric reducers; an all-empty\ngroup yields no value (the header chip is hidden).\n\n## Custom aggregators\n\nPass a function for anything the built-ins don't cover - weighted average,\nmedian, percentile, distinct-with-rules. It receives the finite numeric values\nand the raw leaf rows:\n\n```ts\nconst median = (vals: number[]) =>\n vals.length ? [...vals].sort((a, b) => a - b)[Math.floor(vals.length / 2)] : 0\n\nconst columns = [\n { field: 'score', header: 'Median score', aggregate: median },\n // weighted average using two columns off the raw rows:\n {\n field: 'rate',\n header: 'Blended rate',\n aggregate: (_vals, rows: Row[]) => {\n const w = rows.reduce((s, r) => s + r.weight, 0)\n return w ? rows.reduce((s, r) => s + r.rate * r.weight, 0) / w : 0\n },\n },\n]\n```\n\n## Notes\n\n- Aggregates roll up **all leaf rows** under a group, at every nesting level.\n- The aggregated value is stored on the group row, so\n `api.getDisplayedRows()` and `row.getCellValueByColumnId(id)` return it too -\n handy for exporting group totals.\n- The reducer is exported as `applyGroupAggregate(agg, columnId, rows)` for\n reuse outside the grid.\n\nSee the live [Group aggregators](https://sv-grid.com/demos/142-group-aggregators)\ndemo.\n"
3407
+ "markdown": "# Group aggregators\n\nWhen grouping is active, each group row can show a rolled-up value per column -\nthe sum of revenue, the average score, the count of deals. SvGrid does this\ndeclaratively: set `aggregate` on a column and the group header shows the\nresult, formatted with that column's own `format`.\n\n<div data-docs-demo=\"142-group-aggregators\" data-height=\"480\"></div>\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, tableFeatures, columnGroupingFeature, type ColumnDef } from '@svgrid/grid'\n\n const features = tableFeatures({ columnGroupingFeature })\n\n const columns: ColumnDef<typeof features, Row>[] = [\n { field: 'region', header: 'Region' },\n { field: 'revenue', header: 'Revenue', aggregate: 'sum', format: { type: 'currency', currency: 'USD' } },\n { field: 'winRate', header: 'Win rate', aggregate: 'avg', format: { type: 'percent' } },\n ]\n</script>\n\n<SvGrid {data} {columns} {features} groupable />\n```\n\n## Built-in reducers\n\n| `aggregate` | Result |\n| ----------------- | --------------------------------------------------- |\n| `'sum'` | Sum of the finite numeric values. |\n| `'avg'` | Mean of the finite numeric values. |\n| `'min'` / `'max'` | Smallest / largest value. |\n| `'count'` | Number of leaf rows in the group. |\n| `'countDistinct'` | Number of distinct values. |\n| `'extent'` | `\"min – max\"` range string. |\n| `'first'` | The first leaf row's value (e.g. a shared label). |\n\nNon-numeric and empty cells are ignored by the numeric reducers; an all-empty\ngroup yields no value (the header chip is hidden).\n\n## Custom aggregators\n\nPass a function for anything the built-ins don't cover - weighted average,\nmedian, percentile, distinct-with-rules. It receives the finite numeric values\nand the raw leaf rows:\n\n```ts\nconst median = (vals: number[]) =>\n vals.length ? [...vals].sort((a, b) => a - b)[Math.floor(vals.length / 2)] : 0\n\nconst columns = [\n { field: 'score', header: 'Median score', aggregate: median },\n // weighted average using two columns off the raw rows:\n {\n field: 'rate',\n header: 'Blended rate',\n aggregate: (_vals, rows: Row[]) => {\n const w = rows.reduce((s, r) => s + r.weight, 0)\n return w ? rows.reduce((s, r) => s + r.rate * r.weight, 0) / w : 0\n },\n },\n]\n```\n\n## Notes\n\n- Aggregates roll up **all leaf rows** under a group, at every nesting level.\n- The aggregated value is stored on the group row, so\n `api.getDisplayedRows()` and `row.getCellValueByColumnId(id)` return it too -\n handy for exporting group totals.\n- The reducer is exported as `applyGroupAggregate(agg, columnId, rows)` for\n reuse outside the grid.\n\nSee the live [Group aggregators](https://svgrid.com/demos/142-group-aggregators/)\ndemo.\n"
3387
3408
  },
3388
3409
  {
3389
3410
  "slug": "help/headless/build-a-table",
@@ -3437,13 +3458,13 @@ export const docs = [
3437
3458
  "slug": "help/import",
3438
3459
  "path": "docs/help/import.md",
3439
3460
  "title": "Data import - Enterprise",
3440
- "markdown": "# Data import - Enterprise\r\n\r\nThe sister to [data export and printing](./export.md). Read an Excel\r\nfile, CSV/TSV blob, or JSON array in the browser and produce a typed\r\npreview of every parsed row - including per-cell validation errors -\r\nbefore any data lands in the grid. Ships in the paid\r\n**[@svgrid/enterprise](https://www.npmjs.com/package/@svgrid/enterprise)** add-on.\r\n\r\n![The import pipeline: a file or pasted CSV, TSV, or JSON is parsed into a matrix, source headers are mapped to grid fields, each row is validated, and the clean rows are added to the grid.](/docs-media/grid-import.svg)\r\n\r\nClick **Preview from text** below to run the bundled sample through\r\nthe parser + validator, then **Commit** to push the clean rows into\r\nthe grid:\r\n\r\n<div data-docs-demo=\"53-excel-import\" data-height=\"560\"></div>\r\n\r\n\r\n## What it is\r\n\r\n`installEnterprise(api)` adds one async method to your `SvGridApi`:\r\n\r\n```ts\r\napi.importData(opts): Promise<ImportResult<TData>>\r\n```\r\n\r\nThe call:\r\n\r\n1. Parses the file or text into a typed row set.\r\n2. Maps source columns to your grid's fields via an optional\r\n `columnMap`.\r\n3. Runs each row through your validator (if you give one).\r\n4. Either **returns the result for you to preview**, or **commits the\r\n rows into the grid** via `api.addRows(...)` when `commit: true`.\r\n\r\nThe grid never tries to be a full Excel reader; it handles the shape\r\nExcel, Numbers, Google Sheets, and Apache POI produce by default. For\r\nexotic features (pivot tables embedded in the file, multi-sheet\r\nworkbooks, conditional formatting), pre-process server-side and feed\r\nthe result through this API.\r\n\r\n## When to use it\r\n\r\n- **Onboarding flows** where customers upload a spreadsheet to seed\r\n the app.\r\n- **Bulk edit** workflows where the user downloads a CSV via\r\n `api.exportData(...)`, edits in Excel, and re-uploads.\r\n- **Pipeline integrations** where another tool dumps an xlsx and your\r\n app surfaces it for review.\r\n\r\nIf you control the file format end-to-end and just need server -> grid\r\ndata, skip the importer and call `api.addRows(...)` directly with the\r\nparsed rows. The importer's value is in the **review UX** -\r\ncolumn-mapping, validation, error preview - not the parser itself.\r\n\r\n## Minimal example\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, tableFeatures, rowSortingFeature, type SvGridApi, type ColumnDef } from '@svgrid/grid'\r\n import { installEnterprise, setLicenseKey, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\n setLicenseKey('SVENTERPRISE-XXXX-XXXX-XXXX')\r\n\r\n type Order = { orderId: number; customer: string; total: number }\r\n let rows = $state<Order[]>([])\r\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n\r\n const features = tableFeatures({ rowSortingFeature })\r\n const columns: ColumnDef<typeof features, Order>[] = [\r\n { field: 'orderId', header: 'Order ID' },\r\n { field: 'customer', header: 'Customer' },\r\n { field: 'total', header: 'Total', format: { type: 'currency', currency: 'USD' } },\r\n ]\r\n\r\n async function onFile(e: Event) {\r\n const file = (e.currentTarget as HTMLInputElement).files?.[0]\r\n if (!file || !api) return\r\n const result = await api.importData({\r\n file,\r\n format: 'auto',\r\n columnMap: { 'Order #': 'orderId', 'Customer Name': 'customer', 'Total': 'total' },\r\n validator: (row) => {\r\n const errs = []\r\n if (!row.orderId) errs.push({ field: 'orderId', message: 'required' })\r\n if (row.total < 0) errs.push({ field: 'total', message: 'must be >= 0' })\r\n return errs\r\n },\r\n })\r\n if (result.errors.length === 0) {\r\n api.addRows(result.rows, 'bottom')\r\n } else {\r\n // Render result.errors in your UI for the user to fix.\r\n console.warn(result.errors)\r\n }\r\n }\r\n</script>\r\n\r\n<input type=\"file\" accept=\".xlsx,.csv,.tsv,.json\" onchange={onFile} />\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n onApiReady={(next) => (api = installEnterprise(next))}\r\n/>\r\n```\r\n\r\n## The drop-in dialog: `SvImportDialog`\r\n\r\n`importData` is the engine; **`SvImportDialog`** is the finished UI around\r\nit - the import-side mirror of [`SvExportMenu`](./export.md#the-drop-in-menu-svexportmenu).\r\nGive it your `api` and it handles the whole review flow: drag-drop or\r\npaste, grid-aware auto-mapping, typed preview, per-cell error\r\nhighlighting, and the commit into the grid.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvImportDialog, installEnterprise, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n</script>\r\n\r\n<SvImportDialog\r\n {api}\r\n onImported={(r) => console.log(`imported ${r.rows.length} rows`)}\r\n/>\r\n<SvGrid data={rows} columns={columns} features={features}\r\n onApiReady={(next) => (api = installEnterprise(next))} />\r\n```\r\n\r\nThat's the whole integration. The dialog:\r\n\r\n- **Reads the file once** and re-maps instantly as you retarget columns\r\n (parsing an xlsx never repeats on a mapping change).\r\n- **Auto-maps** the file's headers to your grid's columns by matching\r\n header labels and field names case- and spacing-insensitively, so\r\n `Unit Price`, `unit_price`, and `UnitPrice` all find a column labelled\r\n `Unit price`. Headers with no confident match start unmapped - one click\r\n on their select retargets them.\r\n- **Infers types from your columns' `format`**: a `currency` / `number` /\r\n `percent` column coerces `\"$1,234\"` to `1234`; a `date` column coerces\r\n to an ISO date. Cells that won't convert are flagged in the preview and\r\n keep their raw text (or block the import when `strict` is set).\r\n- **Previews** the typed rows with bad cells highlighted, then commits.\r\n- **Configures + creates columns for new fields.** A header mapped to\r\n *\"Import as new field\"* opens an inline column configurator:\r\n - **Name** - the column header.\r\n - **Kind** - Text / Number / Integer / **Currency** / **Percent** /\r\n Boolean / Date / Date & time / **Dropdown** / JSON. The kind drives the\r\n value coercion **and** the created column's `format` + inline editor\r\n (Currency -> `$` number editor, Percent -> `%`, Date -> date picker,\r\n Boolean -> checkbox, Dropdown -> `select` editor).\r\n - **Visible** - hide the column (`visible: false`) while still importing\r\n its data.\r\n - **Dropdown options** (Dropdown kind) - a comma-separated list; leave it\r\n blank to auto-populate from the distinct values in the imported column.\r\n\r\n On commit each new column is created via `api.addColumn(...)`. So importing\r\n a CSV into an empty grid lets you **define the whole schema** - names,\r\n types, formatting, dropdowns, visibility - as you map. Turn column creation\r\n off with `createColumns={false}`.\r\n- **Append or replace.** The footer has an **Append** / **Replace all**\r\n toggle. Append adds the rows (`api.addRows`); Replace clears the grid's\r\n existing rows and swaps in the imported set (`api.applyTransaction`),\r\n still creating any missing columns. Hide the toggle with\r\n `allowReplace={false}`, or set the initial mode with `defaultMode=\"replace\"`.\r\n- **Replace columns too.** In Replace mode a **Replace columns** checkbox\r\n appears; tick it and the commit also **removes grid columns the file\r\n doesn't have** (`api.removeColumn`), so the grid matches the import\r\n *exactly* - data and columns. Left unticked (the default), existing\r\n columns are kept. Hide the checkbox with `allowColumnPrune={false}`.\r\n\r\nBoth write through the grid's imperative `api`, so the grid updates\r\nimmediately. If your app keeps its own copy of the data (a store, a DB),\r\nuse the `onImported({ rows })` callback to push the imported rows into that\r\nsource too.\r\n\r\nProps: `api`, `label`, `title`, `commitAt`, `createColumns`, `allowReplace`,\r\n`defaultMode`, `allowColumnPrune`, `accept`, `previewRows`, `strict`,\r\n`validator`, `maxBytes`, `maxRows`, `overLimit`, `maxErrors`, `dedupeBy`,\r\n`encoding`, `onImported`.\r\nStyling uses the same overridable CSS variables (`--sg-border`, `--sg-bg`,\r\n`--sg-fg`, `--site-accent`, ...) as the rest of the suite.\r\n\r\n<div data-docs-demo=\"204-import-dialog\" data-height=\"560\"></div>\r\n\r\n### Building your own UI\r\n\r\nIf you want a custom importer, the same parse-once / map-many split is\r\nexported so you don't re-read the file on every keystroke:\r\n\r\n```ts\r\nimport { readImportMatrix, mapImportMatrixAsync, autoMapColumns, inferImportColumnTypes } from '@svgrid/enterprise'\r\n\r\nconst { format, matrix } = await readImportMatrix(file, 'auto', { maxBytes }) // parse once\r\nconst headers = matrix[0]\r\nconst columnMap = autoMapColumns(headers, api.getColumns()) // grid-aware guess\r\nconst columnTypes = inferImportColumnTypes(api.getColumns()) // types from format\r\nconst { rows, errors } = await mapImportMatrixAsync(matrix, { // chunked, cancelable\r\n columnMap, columnTypes, signal, onProgress: (p) => (progress = p.ratio),\r\n})\r\n```\r\n\r\n(There's a synchronous `mapImportMatrix` with the same signature for small\r\ndata / tests.)\r\n\r\n## Supported formats\r\n\r\n| Format | Source type | Peer dependency | Notes |\r\n| ------ | ---------------------- | --------------- | ------------------------------------------------------------------ |\r\n| `xlsx` | `File` / `Blob` | `jszip` | First sheet only. Strings, numbers, booleans, dates as ISO strings. |\r\n| `csv` | `File` / `Blob` / `string` | none | RFC 4180-ish: quoted fields, embedded newlines, escaped quotes. |\r\n| `tsv` | `File` / `Blob` / `string` | none | Same as CSV with `\\t` as separator. |\r\n| `json` | `File` / `Blob` / `string` | none | Top-level array of objects. Column union taken across first ~50 rows. |\r\n| `auto` | any | maybe `jszip` | Format is sniffed from file extension or first character of text. |\r\n\r\nThe xlsx parser shares the `jszip` peer dependency with xlsx export, so\r\nif you already export to Excel you don't add a second peer for import.\r\n\r\n## Column mapping\r\n\r\nPass a `columnMap` from **source header** to **target field**:\r\n\r\n```ts\r\nawait api.importData({\r\n file,\r\n columnMap: {\r\n 'Order #': 'orderId', // rename\r\n 'Customer Name': 'customer',\r\n 'Customer Email': 'email',\r\n 'Internal Note': null, // drop this column entirely\r\n },\r\n})\r\n```\r\n\r\nSource headers not listed in `columnMap` fall through to a default\r\nmapping: lowercase + collapse whitespace to underscores + strip\r\nnon-alphanumerics. So `\"Order ID\"` becomes `order_id`. If that's not\r\nwhat you want, list it explicitly.\r\n\r\nSet a column's map entry to `null` to drop it from the parsed rows\r\nentirely - useful for stripping PII you don't want to land in the\r\nclient-side grid.\r\n\r\n### Auto-mapping\r\n\r\nPass `autoMap: true` to skip the hand-written map entirely. The importer\r\nreads your grid's columns and matches each source header to a field by\r\nheader label first, then field name - case- and spacing-insensitive, so\r\n`\"Unit Price\"`, `\"unit_price\"`, and `\"UnitPrice\"` all find a `unitPrice`\r\ncolumn. It also **infers `columnTypes` from each column's `format`**\r\n(currency / number / percent -> number, date -> ISO date). Anything you\r\npass explicitly in `columnMap` / `columnTypes` still wins over the guess:\r\n\r\n```ts\r\nawait api.importData({ file, autoMap: true }) // fully automatic\r\nawait api.importData({ file, autoMap: true, columnMap: { SKU: 'code' } }) // guess + one override\r\n```\r\n\r\nThis is exactly what `SvImportDialog` uses to line a dropped file up with\r\nyour grid before you touch a single select.\r\n\r\n## Type coercion\r\n\r\nThe parser walks every cell value through a small set of regex-based\r\nheuristics:\r\n\r\n| Source value | Becomes |\r\n| ---------------------- | ------------ |\r\n| `true` / `false` | boolean |\r\n| `123`, `-45.6`, `1e3` | number |\r\n| `$1,234.56` | `1234.56` |\r\n| `\"1,234,567\"` | `1234567` |\r\n| `2024-03-15` | string (ISO date) |\r\n| `2024-03-15T12:30:00Z` | string (ISO datetime) |\r\n| empty cell | `''` |\r\n\r\nThe grid columns then apply their own format / parsing on top. If you\r\nwant fully strict types, run them through your `validator` and reject\r\nanything that didn't coerce the way you expected.\r\n\r\n## Validation\r\n\r\nValidators receive each parsed row plus its index. Return an array of\r\n`{ field, message }` errors:\r\n\r\n```ts\r\nfunction validator(row: Order, rowIndex: number) {\r\n const errs = []\r\n if (row.total < 0) errs.push({ field: 'total', message: 'must be >= 0' })\r\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(row.email))\r\n errs.push({ field: 'email', message: 'invalid email' })\r\n return errs\r\n}\r\n```\r\n\r\nThe errors land in `result.errors` with `rowIndex` (0-based in the\r\noutput, *excluding* the header row) and `field` so your preview UX can\r\nhighlight the offending cell.\r\n\r\n## Preview vs commit\r\n\r\nThe default is **preview**: you get `{ headers, rows, errors, skipped,\r\ntotal, format }` back and decide what to do.\r\n\r\nPass `commit: true` to skip the preview and append the rows directly,\r\nwith an optional `commitAt` ('top' | 'bottom' | numeric index). If\r\nthere are any validator errors, `commit: true` **silently refuses** to\r\nwrite - your UI should always render `result.errors` regardless.\r\n\r\n```ts\r\nconst r = await api.importData({ file, commit: true, commitAt: 'top' })\r\nif (r.errors.length > 0) {\r\n // The commit was skipped. Re-render the import dialog with errors.\r\n}\r\n```\r\n\r\n## Production guard-rails (Enterprise)\r\n\r\nUntrusted uploads need bounds. `importData` (and `SvImportDialog`) take a\r\nset of options that keep a hostile or oversized file from locking up the\r\ntab or exhausting memory:\r\n\r\n| Option | Effect |\r\n| ------------- | ---------------------------------------------------------------------- |\r\n| `maxBytes` | Reject a `File`/`Blob` larger than N bytes **before** it is read. |\r\n| `maxRows` | Cap on data rows (enforced before the O(rows) mapping). |\r\n| `overLimit` | Past `maxRows`: `'reject'` (default, throws) or `'truncate'` (keep first N, set `truncated`). |\r\n| `maxErrors` | Stop collecting validation errors past N (`errorsTruncated` flags it). |\r\n| `encoding` | Decode CSV/TSV/JSON with a non-UTF-8 charset (`'windows-1252'`, ...). |\r\n| `signal` | An `AbortSignal` to cancel a long parse/map (throws an `AbortError`). |\r\n| `onProgress` | `({ phase, ratio, done, total }) => void` for a progress bar. |\r\n| `dedupeBy` | Drop duplicate rows by a target field, keeping the last occurrence. |\r\n\r\n```ts\r\nconst controller = new AbortController()\r\nconst r = await api.importData({\r\n file,\r\n autoMap: true,\r\n maxBytes: 25 * 1024 * 1024, // 25 MB\r\n maxRows: 200_000,\r\n maxErrors: 500,\r\n dedupeBy: 'orderId',\r\n signal: controller.signal,\r\n onProgress: ({ ratio }) => (progress = ratio),\r\n})\r\n```\r\n\r\n`SvImportDialog` surfaces the same knobs as props (`maxBytes` defaults to\r\n25 MB, `maxErrors` to 500), validates the file type / size before reading,\r\nmaps on a chunked non-blocking loop with a progress bar + **Cancel**, and\r\nnever freezes the tab on a large file.\r\n\r\n### Security\r\n\r\n- **Prototype pollution is blocked.** Source headers (or JSON keys) named\r\n `__proto__`, `constructor`, or `prototype` are dropped, never assigned -\r\n so a crafted file can't walk the prototype chain. Blank headers are\r\n dropped too.\r\n- **Nothing is uploaded.** Parsing is entirely client-side; the file never\r\n leaves the browser.\r\n- **The xlsx reader is values-only.** It doesn't resolve external\r\n references or expand XML entities, so it isn't a billion-laughs / XXE\r\n vector.\r\n\r\nFor files beyond a few hundred thousand rows, still prefer a server-side\r\ningest - the guard-rails keep the client safe, but a dedicated backend\r\nparser is the right home for truly large jobs.\r\n\r\n## Result shape\r\n\r\n```ts\r\ntype ImportResult<TData> = {\r\n headers: string[] // source headers verbatim\r\n rows: TData[] // parsed, mapped, type-coerced rows\r\n errors: Array<{ rowIndex: number; field: string; message: string }>\r\n skipped: number // rows skipped because they were entirely blank\r\n total: number // total source rows (incl. blanks + bad rows)\r\n format: 'xlsx' | 'csv' | 'tsv' | 'json'\r\n errorsTruncated?: boolean // maxErrors capped the list\r\n deduped?: number // rows dropped by dedupeBy\r\n truncated?: boolean // maxRows + overLimit:'truncate' dropped trailing rows\r\n}\r\n```\r\n\r\nNote `overLimit:'truncate'` keeps `total` at the **original** source row\r\ncount, so you can report \"imported `rows.length` of `total`\". `maxBytes`\r\nalways rejects - a partial binary/CSV can't be truncated safely.\r\n\r\n## Performance\r\n\r\nThe browser-side parser is O(file size). It walks the bytes once, no\r\nregex backtracking, and pays one `JSON.parse` for JSON imports. For\r\nfiles up to ~100k rows the parse + validate cycle stays under a few\r\nhundred milliseconds on a typical laptop.\r\n\r\nThe **mapping** stage (`mapImportMatrixAsync`, used by `importData` and\r\nthe dialog) runs in chunks and yields to the event loop between them, so\r\neven a 100k-row re-map keeps the tab responsive and drives the progress\r\nbar. Use the synchronous `mapImportMatrix` for small data or unit tests.\r\n\r\nFor larger files (>500k rows) we recommend a server-side ingest:\r\nupload the file, stream it through your parser, and emit the result\r\nback via the same `addRows` call. The importer's review UX still works\r\n- just call it on a sample slice first.\r\n\r\n## Gotchas\r\n\r\n- **First sheet only.** xlsx imports return rows from `sheet1.xml`.\r\n Pick the right sheet server-side or convert the workbook before\r\n upload.\r\n- **No formulas.** Cached formula values are read when present, but the\r\n parser doesn't evaluate uncached formulas.\r\n- **No styles, comments, conditional formatting.** Just values.\r\n- **Blank rows are skipped.** A row whose every cell is empty is\r\n counted in `skipped`, not `rows`.\r\n\r\n## See also\r\n\r\n- [Data export and printing](./export.md) - the round-trip partner.\r\n- [Validation while editing](./editing/validation.md) - the same\r\n validator shape works for inline grid edits.\r\n- [Demo 53 - Excel / CSV import](../../examples/src/demos/53-excel-import.svelte) - the low-level\r\n `importData` + validator flow.\r\n- [Demo 204 - Import dialog + auto-mapping](../../examples/src/demos/204-import-dialog.svelte) - the\r\n drop-in `SvImportDialog` with drag-drop, paste, and grid-aware mapping.\r\n\r\n## Frequently asked questions\r\n\r\n### How do I import an Excel or CSV file into the grid?\r\n\r\nWith `@svgrid/enterprise`, read an xlsx file, CSV/TSV blob, or JSON array in the browser\r\nand get a typed preview of every parsed row - including per-cell validation\r\nerrors - before any data lands in the grid. Nothing is uploaded; parsing happens\r\nclient-side.\r\n\r\n### Does import validate the data?\r\n\r\nYes. Each parsed row runs through the same validator shape used for inline\r\nediting, so you can surface per-cell errors in the preview and let the user fix\r\nthem before committing.\r\n\r\n### Is import free?\r\n\r\nNo. Import ships in `@svgrid/enterprise`, alongside export and pivot. The free\r\nCommunity package handles displaying and editing data you already have in\r\nmemory.\r\n"
3461
+ "markdown": "# Data import - Enterprise\r\n\r\nThe sister to [data export and printing](./export.md). Read an Excel\r\nfile, CSV/TSV blob, or JSON array in the browser and produce a typed\r\npreview of every parsed row - including per-cell validation errors -\r\nbefore any data lands in the grid. Ships in the paid\r\n**[@svgrid/enterprise](https://www.npmjs.com/package/@svgrid/enterprise)** add-on.\r\n\r\n![The import pipeline: a file or pasted CSV, TSV, or JSON is parsed into a matrix, source headers are mapped to grid fields, each row is validated, and the clean rows are added to the grid.](/docs-media/grid-import.svg)\r\n\r\nClick **Preview from text** below to run the bundled sample through\r\nthe parser + validator, then **Commit** to push the clean rows into\r\nthe grid:\r\n\r\n<div data-docs-demo=\"53-excel-import\" data-height=\"560\"></div>\r\n\r\n\r\n## What it is\r\n\r\n`installEnterprise(api)` adds one async method to your `SvGridApi`:\r\n\r\n```ts\r\napi.importData(opts): Promise<ImportResult<TData>>\r\n```\r\n\r\nThe call:\r\n\r\n1. Parses the file or text into a typed row set.\r\n2. Maps source columns to your grid's fields via an optional\r\n `columnMap`.\r\n3. Runs each row through your validator (if you give one).\r\n4. Either **returns the result for you to preview**, or **commits the\r\n rows into the grid** via `api.addRows(...)` when `commit: true`.\r\n\r\nThe grid never tries to be a full Excel reader; it handles the shape\r\nExcel, Numbers, Google Sheets, and Apache POI produce by default. For\r\nexotic features (pivot tables embedded in the file, multi-sheet\r\nworkbooks, conditional formatting), pre-process server-side and feed\r\nthe result through this API.\r\n\r\n## When to use it\r\n\r\n- **Onboarding flows** where customers upload a spreadsheet to seed\r\n the app.\r\n- **Bulk edit** workflows where the user downloads a CSV via\r\n `api.exportData(...)`, edits in Excel, and re-uploads.\r\n- **Pipeline integrations** where another tool dumps an xlsx and your\r\n app surfaces it for review.\r\n\r\nIf you control the file format end-to-end and just need server -> grid\r\ndata, skip the importer and call `api.addRows(...)` directly with the\r\nparsed rows. The importer's value is in the **review UX** -\r\ncolumn-mapping, validation, error preview - not the parser itself.\r\n\r\n## Minimal example\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, tableFeatures, rowSortingFeature, type SvGridApi, type ColumnDef } from '@svgrid/grid'\r\n import { installEnterprise, setLicenseKey, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n\r\n setLicenseKey('SVENTERPRISE-XXXX-XXXX-XXXX')\r\n\r\n type Order = { orderId: number; customer: string; total: number }\r\n let rows = $state<Order[]>([])\r\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n\r\n const features = tableFeatures({ rowSortingFeature })\r\n const columns: ColumnDef<typeof features, Order>[] = [\r\n { field: 'orderId', header: 'Order ID' },\r\n { field: 'customer', header: 'Customer' },\r\n { field: 'total', header: 'Total', format: { type: 'currency', currency: 'USD' } },\r\n ]\r\n\r\n async function onFile(e: Event) {\r\n const file = (e.currentTarget as HTMLInputElement).files?.[0]\r\n if (!file || !api) return\r\n const result = await api.importData({\r\n file,\r\n format: 'auto',\r\n columnMap: { 'Order #': 'orderId', 'Customer Name': 'customer', 'Total': 'total' },\r\n validator: (row) => {\r\n const errs = []\r\n if (!row.orderId) errs.push({ field: 'orderId', message: 'required' })\r\n if (row.total < 0) errs.push({ field: 'total', message: 'must be >= 0' })\r\n return errs\r\n },\r\n })\r\n if (result.errors.length === 0) {\r\n api.addRows(result.rows, 'bottom')\r\n } else {\r\n // Render result.errors in your UI for the user to fix.\r\n console.warn(result.errors)\r\n }\r\n }\r\n</script>\r\n\r\n<input type=\"file\" accept=\".xlsx,.csv,.tsv,.json\" onchange={onFile} />\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n onApiReady={(next) => (api = installEnterprise(next))}\r\n/>\r\n```\r\n\r\n## The drop-in dialog: `SvImportDialog`\r\n\r\n`importData` is the engine; **`SvImportDialog`** is the finished UI around\r\nit - the import-side mirror of [`SvExportMenu`](./export.md#the-drop-in-menu-svexportmenu).\r\nGive it your `api` and it handles the whole review flow: drag-drop or\r\npaste, grid-aware auto-mapping, typed preview, per-cell error\r\nhighlighting, and the commit into the grid.\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvImportDialog, installEnterprise, type EnterpriseGridApi } from '@svgrid/enterprise'\r\n let api = $state<EnterpriseGridApi<typeof features, Order> | null>(null)\r\n</script>\r\n\r\n<SvImportDialog\r\n {api}\r\n onImported={(r) => console.log(`imported ${r.rows.length} rows`)}\r\n/>\r\n<SvGrid data={rows} columns={columns} features={features}\r\n onApiReady={(next) => (api = installEnterprise(next))} />\r\n```\r\n\r\nThat's the whole integration. The dialog:\r\n\r\n- **Reads the file once** and re-maps instantly as you retarget columns\r\n (parsing an xlsx never repeats on a mapping change).\r\n- **Auto-maps** the file's headers to your grid's columns by matching\r\n header labels and field names case- and spacing-insensitively, so\r\n `Unit Price`, `unit_price`, and `UnitPrice` all find a column labelled\r\n `Unit price`. Headers with no confident match start unmapped - one click\r\n on their select retargets them.\r\n- **Infers types from your columns' `format`**: a `currency` / `number` /\r\n `percent` column coerces `\"$1,234\"` to `1234`; a `date` column coerces\r\n to an ISO date. Cells that won't convert are flagged in the preview and\r\n keep their raw text (or block the import when `strict` is set).\r\n- **Previews** the typed rows with bad cells highlighted, then commits.\r\n- **Configures + creates columns for new fields.** A header mapped to\r\n *\"Import as new field\"* opens an inline column configurator:\r\n - **Name** - the column header.\r\n - **Kind** - Text / Number / Integer / **Currency** / **Percent** /\r\n Boolean / Date / Date & time / **Dropdown** / JSON. The kind drives the\r\n value coercion **and** the created column's `format` + inline editor\r\n (Currency -> `$` number editor, Percent -> `%`, Date -> date picker,\r\n Boolean -> checkbox, Dropdown -> `select` editor).\r\n - **Visible** - hide the column (`visible: false`) while still importing\r\n its data.\r\n - **Dropdown options** (Dropdown kind) - a comma-separated list; leave it\r\n blank to auto-populate from the distinct values in the imported column.\r\n\r\n On commit each new column is created via `api.addColumn(...)`. So importing\r\n a CSV into an empty grid lets you **define the whole schema** - names,\r\n types, formatting, dropdowns, visibility - as you map. Turn column creation\r\n off with `createColumns={false}`.\r\n- **Append or replace.** The footer has an **Append** / **Replace all**\r\n toggle. Append adds the rows (`api.addRows`); Replace clears the grid's\r\n existing rows and swaps in the imported set (`api.applyTransaction`),\r\n still creating any missing columns. Hide the toggle with\r\n `allowReplace={false}`, or set the initial mode with `defaultMode=\"replace\"`.\r\n- **Replace columns too.** In Replace mode a **Replace columns** checkbox\r\n appears; tick it and the commit also **removes grid columns the file\r\n doesn't have** (`api.removeColumn`), so the grid matches the import\r\n *exactly* - data and columns. Left unticked (the default), existing\r\n columns are kept. Hide the checkbox with `allowColumnPrune={false}`.\r\n\r\nBoth write through the grid's imperative `api`, so the grid updates\r\nimmediately. If your app keeps its own copy of the data (a store, a DB),\r\nuse the `onImported({ rows })` callback to push the imported rows into that\r\nsource too.\r\n\r\nProps: `api`, `label`, `title`, `commitAt`, `createColumns`, `allowReplace`,\r\n`defaultMode`, `allowColumnPrune`, `accept`, `previewRows`, `strict`,\r\n`validator`, `maxBytes`, `maxRows`, `overLimit`, `maxErrors`, `dedupeBy`,\r\n`encoding`, `onImported`.\r\nStyling uses the same overridable CSS variables (`--sg-border`, `--sg-bg`,\r\n`--sg-fg`, `--site-accent`, ...) as the rest of the suite.\r\n\r\n<div data-docs-demo=\"204-import-dialog\" data-height=\"560\"></div>\r\n\r\n### Building your own UI\r\n\r\nIf you want a custom importer, the same parse-once / map-many split is\r\nexported so you don't re-read the file on every keystroke:\r\n\r\n```ts\r\nimport { readImportMatrix, mapImportMatrixAsync, autoMapColumns, inferImportColumnTypes } from '@svgrid/enterprise'\r\n\r\nconst { format, matrix } = await readImportMatrix(file, 'auto', { maxBytes }) // parse once\r\nconst headers = matrix[0]\r\nconst columnMap = autoMapColumns(headers, api.getColumns()) // grid-aware guess\r\nconst columnTypes = inferImportColumnTypes(api.getColumns()) // types from format\r\nconst { rows, errors } = await mapImportMatrixAsync(matrix, { // chunked, cancelable\r\n columnMap, columnTypes, signal, onProgress: (p) => (progress = p.ratio),\r\n})\r\n```\r\n\r\n(There's a synchronous `mapImportMatrix` with the same signature for small\r\ndata / tests.)\r\n\r\n## Supported formats\r\n\r\n| Format | Source type | Peer dependency | Notes |\r\n| ------ | ---------------------- | --------------- | ------------------------------------------------------------------ |\r\n| `xlsx` | `File` / `Blob` | `jszip` | First sheet only. Strings, numbers, booleans, dates as ISO strings. |\r\n| `csv` | `File` / `Blob` / `string` | none | RFC 4180-ish: quoted fields, embedded newlines, escaped quotes. |\r\n| `tsv` | `File` / `Blob` / `string` | none | Same as CSV with `\\t` as separator. |\r\n| `json` | `File` / `Blob` / `string` | none | Top-level array of objects. Column union taken across first ~50 rows. |\r\n| `auto` | any | maybe `jszip` | Format is sniffed from file extension or first character of text. |\r\n\r\nThe xlsx parser shares the `jszip` peer dependency with xlsx export, so\r\nif you already export to Excel you don't add a second peer for import.\r\n\r\n## Column mapping\r\n\r\nPass a `columnMap` from **source header** to **target field**:\r\n\r\n```ts\r\nawait api.importData({\r\n file,\r\n columnMap: {\r\n 'Order #': 'orderId', // rename\r\n 'Customer Name': 'customer',\r\n 'Customer Email': 'email',\r\n 'Internal Note': null, // drop this column entirely\r\n },\r\n})\r\n```\r\n\r\nSource headers not listed in `columnMap` fall through to a default\r\nmapping: lowercase + collapse whitespace to underscores + strip\r\nnon-alphanumerics. So `\"Order ID\"` becomes `order_id`. If that's not\r\nwhat you want, list it explicitly.\r\n\r\nSet a column's map entry to `null` to drop it from the parsed rows\r\nentirely - useful for stripping PII you don't want to land in the\r\nclient-side grid.\r\n\r\n### Auto-mapping\r\n\r\nPass `autoMap: true` to skip the hand-written map entirely. The importer\r\nreads your grid's columns and matches each source header to a field by\r\nheader label first, then field name - case- and spacing-insensitive, so\r\n`\"Unit Price\"`, `\"unit_price\"`, and `\"UnitPrice\"` all find a `unitPrice`\r\ncolumn. It also **infers `columnTypes` from each column's `format`**\r\n(currency / number / percent -> number, date -> ISO date). Anything you\r\npass explicitly in `columnMap` / `columnTypes` still wins over the guess:\r\n\r\n```ts\r\nawait api.importData({ file, autoMap: true }) // fully automatic\r\nawait api.importData({ file, autoMap: true, columnMap: { SKU: 'code' } }) // guess + one override\r\n```\r\n\r\nThis is exactly what `SvImportDialog` uses to line a dropped file up with\r\nyour grid before you touch a single select.\r\n\r\n## Type coercion\r\n\r\nThe parser walks every cell value through a small set of regex-based\r\nheuristics:\r\n\r\n| Source value | Becomes |\r\n| ---------------------- | ------------ |\r\n| `true` / `false` | boolean |\r\n| `123`, `-45.6`, `1e3` | number |\r\n| `$1,234.56` | `1234.56` |\r\n| `\"1,234,567\"` | `1234567` |\r\n| `2024-03-15` | string (ISO date) |\r\n| `2024-03-15T12:30:00Z` | string (ISO datetime) |\r\n| empty cell | `''` |\r\n\r\nThe grid columns then apply their own format / parsing on top. If you\r\nwant fully strict types, run them through your `validator` and reject\r\nanything that didn't coerce the way you expected.\r\n\r\n## Validation\r\n\r\nValidators receive each parsed row plus its index. Return an array of\r\n`{ field, message }` errors:\r\n\r\n```ts\r\nfunction validator(row: Order, rowIndex: number) {\r\n const errs = []\r\n if (row.total < 0) errs.push({ field: 'total', message: 'must be >= 0' })\r\n if (!/^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(row.email))\r\n errs.push({ field: 'email', message: 'invalid email' })\r\n return errs\r\n}\r\n```\r\n\r\nThe errors land in `result.errors` with `rowIndex` (0-based in the\r\noutput, *excluding* the header row) and `field` so your preview UX can\r\nhighlight the offending cell.\r\n\r\n## Preview vs commit\r\n\r\nThe default is **preview**: you get `{ headers, rows, errors, skipped,\r\ntotal, format }` back and decide what to do.\r\n\r\nPass `commit: true` to skip the preview and append the rows directly,\r\nwith an optional `commitAt` ('top' | 'bottom' | numeric index). If\r\nthere are any validator errors, `commit: true` **silently refuses** to\r\nwrite - your UI should always render `result.errors` regardless.\r\n\r\n```ts\r\nconst r = await api.importData({ file, commit: true, commitAt: 'top' })\r\nif (r.errors.length > 0) {\r\n // The commit was skipped. Re-render the import dialog with errors.\r\n}\r\n```\r\n\r\n## Production guard-rails (Enterprise)\r\n\r\nUntrusted uploads need bounds. `importData` (and `SvImportDialog`) take a\r\nset of options that keep a hostile or oversized file from locking up the\r\ntab or exhausting memory:\r\n\r\n| Option | Effect |\r\n| ------------- | ---------------------------------------------------------------------- |\r\n| `maxBytes` | Reject a `File`/`Blob` larger than N bytes **before** it is read. |\r\n| `maxRows` | Cap on data rows (enforced before the O(rows) mapping). |\r\n| `overLimit` | Past `maxRows`: `'reject'` (default, throws) or `'truncate'` (keep first N, set `truncated`). |\r\n| `maxErrors` | Stop collecting validation errors past N (`errorsTruncated` flags it). |\r\n| `encoding` | Decode CSV/TSV/JSON with a non-UTF-8 charset (`'windows-1252'`, ...). |\r\n| `signal` | An `AbortSignal` to cancel a long parse/map (throws an `AbortError`). |\r\n| `onProgress` | `({ phase, ratio, done, total }) => void` for a progress bar. |\r\n| `dedupeBy` | Drop duplicate rows by a target field, keeping the last occurrence. |\r\n\r\n```ts\r\nconst controller = new AbortController()\r\nconst r = await api.importData({\r\n file,\r\n autoMap: true,\r\n maxBytes: 25 * 1024 * 1024, // 25 MB\r\n maxRows: 200_000,\r\n maxErrors: 500,\r\n dedupeBy: 'orderId',\r\n signal: controller.signal,\r\n onProgress: ({ ratio }) => (progress = ratio),\r\n})\r\n```\r\n\r\n`SvImportDialog` surfaces the same knobs as props (`maxBytes` defaults to\r\n25 MB, `maxErrors` to 500), validates the file type / size before reading,\r\nmaps on a chunked non-blocking loop with a progress bar + **Cancel**, and\r\nnever freezes the tab on a large file.\r\n\r\n### Security\r\n\r\n- **Prototype pollution is blocked.** Source headers (or JSON keys) named\r\n `__proto__`, `constructor`, or `prototype` are dropped, never assigned -\r\n so a crafted file can't walk the prototype chain. Blank headers are\r\n dropped too.\r\n- **Nothing is uploaded.** Parsing is entirely client-side; the file never\r\n leaves the browser.\r\n- **The xlsx reader is values-only.** It doesn't resolve external\r\n references or expand XML entities, so it isn't a billion-laughs / XXE\r\n vector.\r\n\r\nFor files beyond a few hundred thousand rows, still prefer a server-side\r\ningest - the guard-rails keep the client safe, but a dedicated backend\r\nparser is the right home for truly large jobs.\r\n\r\n## Result shape\r\n\r\n```ts\r\ntype ImportResult<TData> = {\r\n headers: string[] // source headers verbatim\r\n rows: TData[] // parsed, mapped, type-coerced rows\r\n errors: Array<{ rowIndex: number; field: string; message: string }>\r\n skipped: number // rows skipped because they were entirely blank\r\n total: number // total source rows (incl. blanks + bad rows)\r\n format: 'xlsx' | 'csv' | 'tsv' | 'json'\r\n errorsTruncated?: boolean // maxErrors capped the list\r\n deduped?: number // rows dropped by dedupeBy\r\n truncated?: boolean // maxRows + overLimit:'truncate' dropped trailing rows\r\n}\r\n```\r\n\r\nNote `overLimit:'truncate'` keeps `total` at the **original** source row\r\ncount, so you can report \"imported `rows.length` of `total`\". `maxBytes`\r\nalways rejects - a partial binary/CSV can't be truncated safely.\r\n\r\n## Performance\r\n\r\nThe browser-side parser is O(file size). It walks the bytes once, no\r\nregex backtracking, and pays one `JSON.parse` for JSON imports. For\r\nfiles up to ~100k rows the parse + validate cycle stays under a few\r\nhundred milliseconds on a typical laptop.\r\n\r\nThe **mapping** stage (`mapImportMatrixAsync`, used by `importData` and\r\nthe dialog) runs in chunks and yields to the event loop between them, so\r\neven a 100k-row re-map keeps the tab responsive and drives the progress\r\nbar. Use the synchronous `mapImportMatrix` for small data or unit tests.\r\n\r\nFor larger files (>500k rows) we recommend a server-side ingest:\r\nupload the file, stream it through your parser, and emit the result\r\nback via the same `addRows` call. The importer's review UX still works\r\n- just call it on a sample slice first.\r\n\r\n## Gotchas\r\n\r\n- **First sheet only.** xlsx imports return rows from `sheet1.xml`.\r\n Pick the right sheet server-side or convert the workbook before\r\n upload.\r\n- **No formulas.** Cached formula values are read when present, but the\r\n parser doesn't evaluate uncached formulas.\r\n- **No styles, comments, conditional formatting.** Just values.\r\n- **Blank rows are skipped.** A row whose every cell is empty is\r\n counted in `skipped`, not `rows`.\r\n\r\n## See also\r\n\r\n- [Data export and printing](./export.md) - the round-trip partner.\r\n- [Validation while editing](./editing/validation.md) - the same\r\n validator shape works for inline grid edits.\r\n- [Demo 53 - Excel / CSV import](../../examples/src/demos/53-excel-import.svelte) - the low-level\r\n `importData` + validator flow.\r\n- [Demo 204 - Import dialog + auto-mapping](../../examples/src/demos/204-import-dialog.svelte) - the\r\n drop-in `SvImportDialog` with drag-drop, paste, and grid-aware mapping.\r\n\r\n## Frequently asked questions\r\n\r\n### How do I import an Excel or CSV file into the grid?\r\n\r\nWith `@svgrid/enterprise`, read an xlsx file, CSV/TSV blob, or JSON array in the browser\r\nand get a typed preview of every parsed row - including per-cell validation\r\nerrors - before any data lands in the grid. Nothing is uploaded; parsing happens\r\nclient-side.\r\n\r\n### Does import validate the data?\r\n\r\nYes. Each parsed row runs through the same validator shape used for inline\r\nediting, so you can surface per-cell errors in the preview and let the user fix\r\nthem before committing.\r\n\r\n### Is import free?\r\n\r\nNo. Import ships in `@svgrid/enterprise`, alongside export and pivot. The free\r\nCommunity package handles displaying and editing data you already have in\r\nmemory.\r\n"
3441
3462
  },
3442
3463
  {
3443
3464
  "slug": "help/index",
3444
3465
  "path": "docs/help/index.md",
3445
3466
  "title": "SvGrid Help",
3446
- "markdown": "# SvGrid Help\r\n\r\nTopic-oriented documentation for SvGrid. Each page is a focused\r\nexplanation of one feature with copy-paste code that runs against the\r\nshipping library - written for SvGrid, not translated from another grid.\r\n\r\nStart with [Getting Started](../getting-started.md) if you have not\r\nalready.\r\n\r\n> **Tier badges.** Pages whose title ends with `- Enterprise` describe a\r\n> feature that ships in the paid `@svgrid/enterprise` add-on. Everything else\r\n> is part of the open-source `@svgrid/grid` package. The same\r\n> visual convention is used throughout this documentation.\r\n\r\nLive reference - the trading-desk demo runs the full feature set at\r\nreal-world scale:\r\n\r\n<div data-docs-demo=\"00-trading-desk\" data-height=\"560\"></div>\r\n\r\n## Background\r\n\r\n- [Why headless?](../why-headless.md) - what the headless core gives you and when to reach for it\r\n- [Architecture overview](./architecture.md) - the three-layer model: your data, the engine, the renderer\r\n- [Glossary](./glossary.md) - terminology used across the docs (accessor, snippet, row model, ...)\r\n- [Tailwind integration](./tailwind.md) - re-theming the grid via `--sg-*` tokens, dark-mode wiring, what *not* to do\r\n- [**Enterprise feature pack**](../enterprise/README.md) - landing page for the paid add-on; what's in it + how to license it\r\n- [Data export and printing - Enterprise](./export.md) - Excel, PDF, CSV, TSV, HTML, and Print\r\n- [Data import - Enterprise](./import.md) - Excel, CSV, TSV, and JSON import with column mapping + validation\r\n- [**AI Toolkit**](./ai-toolkit.md) - the hub for every AI feature: in-grid helpers, agents, MCP server, and LLM grounding, all model-agnostic\r\n- [AI assistant](./ai.md) - natural-language filter, smart fill, summarise, classify; free in @svgrid/grid, bring-your-own model adapter\r\n- [Pivot tables - Enterprise](./pivot.md) - `createPivotModel` + nested column headers; designer UI is a separate demo\r\n- [Alerts - Enterprise](./alerts.md) - no-code alert rules: toast / highlight / flash / prevent-edit when the data crosses a line; persisted + shareable\r\n- [Expression query language - Enterprise](./expressions-query.md) - the predicate / scalar / change language behind alerts (and styled + calculated columns)\r\n- **Migrating to SvGrid** - column / API translation guides from other grids:\r\n [AG Grid](./migrating-from-ag-grid.md) ·\r\n [TanStack Table](./migrating-from-tanstack-table.md) ·\r\n [MUI X DataGrid](./migrating-from-mui-x.md) ·\r\n [Handsontable](./migrating-from-handsontable.md) ·\r\n [Glide Data Grid](./migrating-from-glide.md) ·\r\n [svelte-headless-table](./migrating-from-svelte-headless-table.md) ·\r\n [SVAR Svelte DataGrid](./migrating-from-svar-svelte-datagrid.md) ·\r\n [@vincjo/datatables](./migrating-from-vincjo-datatables.md) ·\r\n [Flowbite / Skeleton / shadcn tables](./migrating-from-ui-kit-tables.md) ·\r\n [Tabulator](./migrating-from-tabulator.md) ·\r\n [Grid.js](./migrating-from-gridjs.md) ·\r\n [React Data Grid](./migrating-from-react-data-grid.md) ·\r\n [PrimeVue / PrimeNG / PrimeReact](./migrating-from-primevue-datatable.md) ·\r\n [Kendo UI Grid](./migrating-from-kendo-grid.md) ·\r\n [DevExtreme](./migrating-from-devextreme.md) ·\r\n [Syncfusion](./migrating-from-syncfusion.md) ·\r\n [jqxGrid](./migrating-from-jqxgrid.md) ·\r\n [Smart.Grid](./migrating-from-smart-grid.md)\r\n\r\n## Patterns & playbooks\r\n\r\n- [Recipes / Cookbook](./recipes.md) - 20+ copy-paste patterns from sort/filter/paginate to inline edit + cascading totals to streaming, each paired with a live interactive demo\r\n- [AI Smart Paste](./ai-smart-paste.md) - vCard / Markdown / signature-block / CSV parsing with email typo correction, phone normalization, and multi-language headers\r\n- [Spreadsheet formulas](./spreadsheet-formulas.md) - in-cell `=SUM` / `=IF` / `=COUNTIF` with cell refs, ranges, cycle detection\r\n- [Mobile / responsive card view](./mobile-card-view.md) - the same `$state` array driving a desktop grid and a touch-friendly card list\r\n- [Conditional form schema](./conditional-form-schema.md) - declarative `when` rules for per-cell visibility and editability\r\n- [Server-side data](./server-side-data.md) - paginated fetch, server-driven sort + filter, sparse infinite scroll (with velocity-aware chunk loading + abort guards)\r\n- [Real-time / streaming updates](./real-time.md) - poll vs WebSocket, delta merge, pause-while-editing, backpressure\r\n- [Grouping & aggregation](./grouping-aggregation.md) - built-in aggregators, custom group cells, group-vs-leaf sort, performance notes\r\n- [Columns hierarchy + manager](./columns-hierarchy.md) - side-panel column tree with drag-to-reorder, collapsible groups, summary columns\r\n- [State maintenance](./state-maintenance.md) - capture / apply, undo / redo, bookmarks, JSON IO, debounced auto-save\r\n- [Saved views & persistence](./saved-views.md) - localStorage / server-side persistence, view migration, URL sharing\r\n- [Internationalisation & RTL](./i18n-rtl.md) - locale-aware formatting, RTL layout flip, CJK column widths, mixed-direction safety\r\n\r\n## Enterprise readiness\r\n\r\n- [Security & supply chain](./security.md) - peer-dep table, CSP guidance, SBOM generation, vulnerability handling, data residency\r\n- [Browser & runtime support](./browser-support.md) - tested browsers + versions, SSR runtimes, build tools, DOM-API requirements, mobile\r\n- [Accessibility](./accessibility.md) - WAI-ARIA 1.2 grid pattern, WCAG 2.1 AA mapping, keyboard map, forced-colors + reduced-motion\r\n- [Performance benchmarks](./benchmarks.md) - first paint, sustained scroll FPS, sort / filter / group, memory, bundle size, on a documented machine\r\n- [Testing your grid](./testing.md) - unit tests against the engine, jsdom component tests, Playwright e2e, axe-core for a11y regressions\r\n- [API stability & semver policy](./api-stability.md) - the promise we make to you about breaking changes + deprecation lifecycle\r\n- [**API reference**](../reference/index.md) - exhaustive prop/method/type tables for `<SvGrid>`, `SvGridApi`, `ColumnDef`, features, and the Enterprise surface\r\n- [API stability badges](./api-reference.md) - flat index of every Stable export with its tier badge\r\n- [Changelog](../changelog.md) - reverse-chronological log of every shipped change\r\n- [Error reference](./errors.md) - every typed error this surface throws, with the trigger and the fix\r\n\r\n## Production checklist\r\n\r\nA focused walkthrough for the questions enterprise teams ask before\r\nshipping. Each link drops you straight into the relevant topic page.\r\n\r\n| Concern | Reach for |\r\n| -------------------------------- | -------------------------------------------------------------------------------------------------- |\r\n| Performance with >10k rows | [Performance benchmarks](./benchmarks.md) + [Row pagination](./rows/row-pagination.md) |\r\n| Server-side data | [Server-side data](./server-side-data.md) - paginated fetch, server-driven sort, sparse infinite scroll |\r\n| Real-time / streaming updates | [Real-time / streaming](./real-time.md) - delta merge, pause-while-editing, backpressure |\r\n| Tree / hierarchical data | [Tree rows](./rows/tree-rows.md) - flat-array + expanded-map pattern, lazy load, keyboard nav |\r\n| Pivot / multi-level headers | [Pivot tables](./pivot.md) + [Column groups](./columns/column-groups.md) |\r\n| Grouping + aggregation | [Grouping & aggregation](./grouping-aggregation.md) - built-in aggregators + custom group cells |\r\n| Inline editing with validation | [Editing overview](./editing/overview.md) + [Validation while editing](./editing/validation.md) |\r\n| Theming + dark mode | [Tailwind integration](./tailwind.md) - the `--sg-*` token list |\r\n| Accessibility / WAI-ARIA | [Accessibility](./accessibility.md) - WCAG 2.1 AA mapping + keyboard map + forced-colors |\r\n| Internationalisation / RTL | [Internationalisation & RTL](./i18n-rtl.md) - locales, formatting, mixed-direction safety |\r\n| Saved views / layout persistence | [Saved views](./saved-views.md) - localStorage / server-side persistence + migration |\r\n| Export to Excel / PDF / CSV | [Data export and printing](./export.md) |\r\n| Excel / CSV / JSON import | [Data import](./import.md) - file picker -> column map -> preview -> commit |\r\n| Add AI to the grid | [AI assistant](./ai.md) - one provider adapter, four helpers |\r\n| Multi-app deployment licensing | [Pricing](https://svgrid.com/pricing/) - Multiple App License covers an org |\r\n| Testing the grid | [Testing your grid](./testing.md) + [Testing and quality](./testing-and-quality.md) |\r\n\r\nIf a question is missing from this table, **press `Ctrl/Cmd + K`** in\r\nthe docs sidebar - the search box indexes every page's title, headings,\r\nand body and ranks matches by where they hit.\r\n\r\n## Core features\r\n\r\n### Columns\r\n\r\n- [Column definitions](./columns/column-definitions.md)\r\n- [Updating definitions](./columns/updating-definitions.md)\r\n- [Column state](./columns/column-state.md)\r\n- [Column headers - styling & height](./columns/column-headers.md)\r\n- [Column groups](./columns/column-groups.md)\r\n- [Column sizing](./columns/column-sizing.md)\r\n- [Column moving](./columns/column-moving.md)\r\n- [Column pinning](./columns/column-pinning.md)\r\n- [Column spanning](./columns/column-spanning.md)\r\n- [Aligned grids](./columns/aligned-grids.md)\r\n- [Column menu](./columns/column-menu.md)\r\n- [Custom header components](./columns/custom-header-components.md)\r\n\r\n### Rows\r\n\r\n- [Row data](./rows/row-data.md)\r\n- [Row sorting](./rows/row-sorting.md)\r\n- [Row spanning](./rows/row-spanning.md)\r\n- [Row pinning](./rows/row-pinning.md)\r\n- [Row height](./rows/row-height.md)\r\n- [Styling rows](./rows/styling-rows.md)\r\n- [Row pagination](./rows/row-pagination.md)\r\n- [Accessing rows](./rows/accessing-rows.md)\r\n- [Row dragging](./rows/row-dragging.md)\r\n- [Full-width rows](./rows/full-width-rows.md)\r\n- [Master / detail (nested grids)](./rows/master-detail.md)\r\n- [Tree rows (expand / collapse)](./rows/tree-rows.md)\r\n\r\n### Cells\r\n\r\n- [Getting values](./cells/getting-values.md)\r\n- [Text formatting](./cells/text-formatting.md)\r\n- [Cell components](./cells/cell-components.md)\r\n- [Cell data types](./cells/cell-data-types.md)\r\n- [Styling cells](./cells/styling-cells.md)\r\n- [Highlighting changes](./cells/highlighting-changes.md)\r\n- [Tooltips](./cells/tooltips.md)\r\n- [Expressions](./cells/expressions.md)\r\n- [View refresh](./cells/view-refresh.md)\r\n- [Cell text selection](./cells/cell-text-selection.md)\r\n\r\n### Filtering\r\n\r\n- [Overview](./filtering/overview.md)\r\n- [Text filter](./filtering/text-filter.md)\r\n- [Number filter](./filtering/number-filter.md)\r\n- [Date filter](./filtering/date-filter.md)\r\n- [Set filter](./filtering/set-filter.md)\r\n- [Filter conditions](./filtering/filter-conditions.md)\r\n- [Applying filters](./filtering/applying-filters.md)\r\n- [Filter API](./filtering/filter-api.md)\r\n- [Custom column filters](./filtering/custom-column-filters.md)\r\n- [Floating filters](./filtering/floating-filters.md)\r\n\r\n### Editing\r\n\r\n- [Overview](./editing/overview.md)\r\n- [Start / stop editing](./editing/start-stop-editing.md)\r\n- [Parsing values](./editing/parsing-values.md)\r\n- [Saving values](./editing/saving-values.md)\r\n- [Edit components](./editing/edit-components.md)\r\n- [Provided cell editors](./editing/provided-editors.md)\r\n- [Undo / redo](./editing/undo-redo.md)\r\n- [Full-row editing](./editing/full-row.md)\r\n- [Validation](./editing/validation.md)\r\n\r\n## Conventions\r\n\r\nEach topic page is structured as:\r\n\r\n1. **What it is** - a one-sentence definition.\r\n2. **When to use it** - the situation that calls for this feature.\r\n3. **Minimal example** - copy-pasteable code that runs.\r\n4. **Reference** - the relevant exports and prop names.\r\n5. **Gotchas** - known limits, gaps, or things that surprise people.\r\n\r\nPages explicitly note when a feature is **not yet implemented** in the\r\ncommunity build so you know what you can rely on. The current\r\ngap list is at [missing-features.md](./missing-features.md).\r\n"
3467
+ "markdown": "# SvGrid Help\r\n\r\nTopic-oriented documentation for SvGrid. Each page is a focused\r\nexplanation of one feature with copy-paste code that runs against the\r\nshipping library - written for SvGrid, not translated from another grid.\r\n\r\nStart with [Getting Started](../getting-started.md) if you have not\r\nalready.\r\n\r\n> **Tier badges.** Pages whose title ends with `- Enterprise` describe a\r\n> feature that ships in the paid `@svgrid/enterprise` add-on. Everything else\r\n> is part of the open-source `@svgrid/grid` package. The same\r\n> visual convention is used throughout this documentation.\r\n\r\nLive reference - the trading-desk demo runs the full feature set at\r\nreal-world scale:\r\n\r\n<div data-docs-demo=\"00-trading-desk\" data-height=\"560\"></div>\r\n\r\n## Background\r\n\r\n- [Why headless?](../why-headless.md) - what the headless core gives you and when to reach for it\r\n- [Architecture overview](./architecture.md) - the three-layer model: your data, the engine, the renderer\r\n- [Glossary](./glossary.md) - terminology used across the docs (accessor, snippet, row model, ...)\r\n- [Tailwind integration](./tailwind.md) - re-theming the grid via `--sg-*` tokens, dark-mode wiring, what *not* to do\r\n- [**Enterprise feature pack**](../enterprise/README.md) - landing page for the paid add-on; what's in it + how to license it\r\n- [Data export and printing - Enterprise](./export.md) - Excel, PDF, CSV, TSV, HTML, and Print\r\n- [Data import - Enterprise](./import.md) - Excel, CSV, TSV, and JSON import with column mapping + validation\r\n- [**AI Toolkit**](./ai-toolkit.md) - the hub for every AI feature: in-grid helpers, agents, MCP server, and LLM grounding, all model-agnostic\r\n- [AI assistant](./ai.md) - natural-language filter, smart fill, summarise, classify; free in @svgrid/grid, bring-your-own model adapter\r\n- [Pivot tables - Enterprise](./pivot.md) - `createPivotModel` + nested column headers; designer UI is a separate demo\r\n- [Alerts - Enterprise](./alerts.md) - no-code alert rules: toast / highlight / flash / prevent-edit when the data crosses a line; persisted + shareable\r\n- [Expression query language - Enterprise](./expressions-query.md) - the predicate / scalar / change language behind alerts (and styled + calculated columns)\r\n- **Migrating to SvGrid** - column / API translation guides from other grids:\r\n [AG Grid](./migrating-from-ag-grid.md) Ā·\r\n [TanStack Table](./migrating-from-tanstack-table.md) Ā·\r\n [MUI X DataGrid](./migrating-from-mui-x.md) Ā·\r\n [Handsontable](./migrating-from-handsontable.md) Ā·\r\n [Glide Data Grid](./migrating-from-glide.md) Ā·\r\n [svelte-headless-table](./migrating-from-svelte-headless-table.md) Ā·\r\n [SVAR Svelte DataGrid](./migrating-from-svar-svelte-datagrid.md) Ā·\r\n [@vincjo/datatables](./migrating-from-vincjo-datatables.md) Ā·\r\n [Flowbite / Skeleton / shadcn tables](./migrating-from-ui-kit-tables.md) Ā·\r\n [Tabulator](./migrating-from-tabulator.md) Ā·\r\n [Grid.js](./migrating-from-gridjs.md) Ā·\r\n [React Data Grid](./migrating-from-react-data-grid.md) Ā·\r\n [PrimeVue / PrimeNG / PrimeReact](./migrating-from-primevue-datatable.md) Ā·\r\n [Kendo UI Grid](./migrating-from-kendo-grid.md) Ā·\r\n [DevExtreme](./migrating-from-devextreme.md) Ā·\r\n [Syncfusion](./migrating-from-syncfusion.md) Ā·\r\n [jqxGrid](./migrating-from-jqxgrid.md) Ā·\r\n [Smart.Grid](./migrating-from-smart-grid.md)\r\n\r\n## Patterns & playbooks\r\n\r\n- [Recipes / Cookbook](./recipes.md) - 20+ copy-paste patterns from sort/filter/paginate to inline edit + cascading totals to streaming, each paired with a live interactive demo\r\n- [AI Smart Paste](./ai-smart-paste.md) - vCard / Markdown / signature-block / CSV parsing with email typo correction, phone normalization, and multi-language headers\r\n- [Spreadsheet formulas](./spreadsheet-formulas.md) - in-cell `=SUM` / `=IF` / `=COUNTIF` with cell refs, ranges, cycle detection\r\n- [Mobile / responsive card view](./mobile-card-view.md) - the same `$state` array driving a desktop grid and a touch-friendly card list\r\n- [Conditional form schema](./conditional-form-schema.md) - declarative `when` rules for per-cell visibility and editability\r\n- [Server-side data](./server-side-data.md) - paginated fetch, server-driven sort + filter, sparse infinite scroll (with velocity-aware chunk loading + abort guards)\r\n- [Real-time / streaming updates](./real-time.md) - poll vs WebSocket, delta merge, pause-while-editing, backpressure\r\n- [Grouping & aggregation](./grouping-aggregation.md) - built-in aggregators, custom group cells, group-vs-leaf sort, group footers, grouped paging\r\n- [Tree data](./rows/tree-data.md) - hierarchical rows from flat parent-id or nested children, treegrid keyboard + ARIA\r\n- [Columns hierarchy + manager](./columns-hierarchy.md) - side-panel column tree with drag-to-reorder, collapsible groups, summary columns\r\n- [State maintenance](./state-maintenance.md) - capture / apply, undo / redo, bookmarks, JSON IO, debounced auto-save\r\n- [Saved views & persistence](./saved-views.md) - localStorage / server-side persistence, view migration, URL sharing\r\n- [Internationalisation & RTL](./i18n-rtl.md) - locale-aware formatting, RTL layout flip, CJK column widths, mixed-direction safety\r\n\r\n## Enterprise readiness\r\n\r\n- [Security & supply chain](./security.md) - peer-dep table, CSP guidance, SBOM generation, vulnerability handling, data residency\r\n- [Browser & runtime support](./browser-support.md) - tested browsers + versions, SSR runtimes, build tools, DOM-API requirements, mobile\r\n- [Accessibility](./accessibility.md) - WAI-ARIA 1.2 grid pattern, WCAG 2.1 AA mapping, keyboard map, forced-colors + reduced-motion\r\n- [Performance benchmarks](./benchmarks.md) - first paint, sustained scroll FPS, sort / filter / group, memory, bundle size, on a documented machine\r\n- [Testing your grid](./testing.md) - unit tests against the engine, jsdom component tests, Playwright e2e, axe-core for a11y regressions\r\n- [API stability & semver policy](./api-stability.md) - the promise we make to you about breaking changes + deprecation lifecycle\r\n- [**API reference**](../reference/index.md) - exhaustive prop/method/type tables for `<SvGrid>`, `SvGridApi`, `ColumnDef`, features, and the Enterprise surface\r\n- [API stability badges](./api-reference.md) - flat index of every Stable export with its tier badge\r\n- [Changelog](../changelog.md) - reverse-chronological log of every shipped change\r\n- [Error reference](./errors.md) - every typed error this surface throws, with the trigger and the fix\r\n\r\n## Production checklist\r\n\r\nA focused walkthrough for the questions enterprise teams ask before\r\nshipping. Each link drops you straight into the relevant topic page.\r\n\r\n| Concern | Reach for |\r\n| -------------------------------- | -------------------------------------------------------------------------------------------------- |\r\n| Performance with >10k rows | [Performance benchmarks](./benchmarks.md) + [Row pagination](./rows/row-pagination.md) |\r\n| Server-side data | [Server-side data](./server-side-data.md) - paginated fetch, server-driven sort, sparse infinite scroll |\r\n| Real-time / streaming updates | [Real-time / streaming](./real-time.md) - delta merge, pause-while-editing, backpressure |\r\n| Tree / hierarchical data | [Tree rows](./rows/tree-rows.md) - flat-array + expanded-map pattern, lazy load, keyboard nav |\r\n| Pivot / multi-level headers | [Pivot tables](./pivot.md) + [Column groups](./columns/column-groups.md) |\r\n| Grouping + aggregation | [Grouping & aggregation](./grouping-aggregation.md) - built-in aggregators + custom group cells |\r\n| Inline editing with validation | [Editing overview](./editing/overview.md) + [Validation while editing](./editing/validation.md) |\r\n| Theming + dark mode | [Tailwind integration](./tailwind.md) - the `--sg-*` token list |\r\n| Accessibility / WAI-ARIA | [Accessibility](./accessibility.md) - WCAG 2.1 AA mapping + keyboard map + forced-colors |\r\n| Internationalisation / RTL | [Internationalisation & RTL](./i18n-rtl.md) - locales, formatting, mixed-direction safety |\r\n| Saved views / layout persistence | [Saved views](./saved-views.md) - localStorage / server-side persistence + migration |\r\n| Export to Excel / PDF / CSV | [Data export and printing](./export.md) |\r\n| Excel / CSV / JSON import | [Data import](./import.md) - file picker -> column map -> preview -> commit |\r\n| Add AI to the grid | [AI assistant](./ai.md) - one provider adapter, four helpers |\r\n| Multi-app deployment licensing | [Pricing](https://svgrid.com/pricing/) - Multiple App License covers an org |\r\n| Testing the grid | [Testing your grid](./testing.md) + [Testing and quality](./testing-and-quality.md) |\r\n\r\nIf a question is missing from this table, **press `Ctrl/Cmd + K`** in\r\nthe docs sidebar - the search box indexes every page's title, headings,\r\nand body and ranks matches by where they hit.\r\n\r\n## Core features\r\n\r\n### Columns\r\n\r\n- [Column definitions](./columns/column-definitions.md)\r\n- [Updating definitions](./columns/updating-definitions.md)\r\n- [Column state](./columns/column-state.md)\r\n- [Column headers - styling & height](./columns/column-headers.md)\r\n- [Column groups](./columns/column-groups.md)\r\n- [Column sizing](./columns/column-sizing.md)\r\n- [Column moving](./columns/column-moving.md)\r\n- [Column pinning](./columns/column-pinning.md)\r\n- [Column spanning](./columns/column-spanning.md)\r\n- [Aligned grids](./columns/aligned-grids.md)\r\n- [Column menu](./columns/column-menu.md)\r\n- [Custom header components](./columns/custom-header-components.md)\r\n\r\n### Rows\r\n\r\n- [Row data](./rows/row-data.md)\r\n- [Row sorting](./rows/row-sorting.md)\r\n- [Row spanning](./rows/row-spanning.md)\r\n- [Row pinning](./rows/row-pinning.md)\r\n- [Row height](./rows/row-height.md)\r\n- [Styling rows](./rows/styling-rows.md)\r\n- [Row pagination](./rows/row-pagination.md)\r\n- [Accessing rows](./rows/accessing-rows.md)\r\n- [Row dragging](./rows/row-dragging.md)\r\n- [Full-width rows](./rows/full-width-rows.md)\r\n- [Master / detail (nested grids)](./rows/master-detail.md)\r\n- [Tree rows (expand / collapse)](./rows/tree-rows.md)\r\n\r\n### Cells\r\n\r\n- [Getting values](./cells/getting-values.md)\r\n- [Text formatting](./cells/text-formatting.md)\r\n- [Cell components](./cells/cell-components.md)\r\n- [Cell data types](./cells/cell-data-types.md)\r\n- [Styling cells](./cells/styling-cells.md)\r\n- [Highlighting changes](./cells/highlighting-changes.md)\r\n- [Tooltips](./cells/tooltips.md)\r\n- [Expressions](./cells/expressions.md)\r\n- [View refresh](./cells/view-refresh.md)\r\n- [Cell text selection](./cells/cell-text-selection.md)\r\n\r\n### Filtering\r\n\r\n- [Overview](./filtering/overview.md)\r\n- [Text filter](./filtering/text-filter.md)\r\n- [Number filter](./filtering/number-filter.md)\r\n- [Date filter](./filtering/date-filter.md)\r\n- [Set filter](./filtering/set-filter.md)\r\n- [Filter conditions](./filtering/filter-conditions.md)\r\n- [Applying filters](./filtering/applying-filters.md)\r\n- [Filter API](./filtering/filter-api.md)\r\n- [Custom column filters](./filtering/custom-column-filters.md)\r\n- [Floating filters](./filtering/floating-filters.md)\r\n\r\n### Editing\r\n\r\n- [Overview](./editing/overview.md)\r\n- [Start / stop editing](./editing/start-stop-editing.md)\r\n- [Parsing values](./editing/parsing-values.md)\r\n- [Saving values](./editing/saving-values.md)\r\n- [Edit components](./editing/edit-components.md)\r\n- [Provided cell editors](./editing/provided-editors.md)\r\n- [Undo / redo](./editing/undo-redo.md)\r\n- [Full-row editing](./editing/full-row.md)\r\n- [Validation](./editing/validation.md)\r\n\r\n## Conventions\r\n\r\nEach topic page is structured as:\r\n\r\n1. **What it is** - a one-sentence definition.\r\n2. **When to use it** - the situation that calls for this feature.\r\n3. **Minimal example** - copy-pasteable code that runs.\r\n4. **Reference** - the relevant exports and prop names.\r\n5. **Gotchas** - known limits, gaps, or things that surprise people.\r\n\r\nPages explicitly note when a feature is **not yet implemented** in the\r\ncommunity build so you know what you can rely on. The current\r\ngap list is at [missing-features.md](./missing-features.md).\r\n"
3447
3468
  },
3448
3469
  {
3449
3470
  "slug": "help/llm-grounding",
@@ -3461,7 +3482,7 @@ export const docs = [
3461
3482
  "slug": "help/migrating-from-ag-grid",
3462
3483
  "path": "docs/help/migrating-from-ag-grid.md",
3463
3484
  "title": "Migrating from AG Grid to SvGrid",
3464
- "markdown": "# Migrating from AG Grid to SvGrid\r\n\r\nIf you tried AG Grid on a Svelte 5 project - via `ag-grid-svelte`, the\r\nold `ag-grid-community/svelte`, or a hand-rolled wrapper - you probably\r\nhit the same friction everyone hits: the bridge between AG Grid's\r\nReact/Angular-first API and Svelte 5 runes is brittle, the bundle is\r\nheavy, and the Enterprise pricing only makes sense at scale.\r\n\r\nThis page is a 30-minute migration recipe from AG Grid to SvGrid. It\r\ncovers what maps 1:1, what's different by design, and what you'll lose.\r\nWe tell you when **not** to switch at the bottom.\r\n\r\n## TL;DR\r\n\r\n| | AG Grid Community | AG Grid Enterprise | SvGrid Community | @svgrid/enterprise |\r\n| --- | --- | --- | --- | --- |\r\n| **License** | MIT | Commercial (~$999/dev/yr) | **MIT** | $599/dev/yr (single app) or $999/dev/yr (multi app) |\r\n| **Svelte 5 native** | āŒ (wrapper) | āŒ (wrapper) | āœ… | āœ… |\r\n| **Bundle (gzipped)** | ~250 KB | ~400 KB | ~2 KB headless / ~80 KB full | lazy-loaded subpaths |\r\n| **Sorting / filtering / grouping** | āœ… | āœ… | āœ… | (in Community) |\r\n| **Master/detail, tree, range select** | āŒ Enterprise only | āœ… | āœ… (free) | (in Community) |\r\n| **Excel export** | āŒ | āœ… Enterprise | āŒ | āœ… |\r\n| **PDF / CSV / TSV / HTML export** | āŒ | Partial | āŒ | āœ… |\r\n| **Print view** | āŒ | āŒ | āŒ | āœ… |\r\n| **Set filter / Excel-style filter menu** | āŒ Enterprise | āœ… | āœ… (free) | (in Community) |\r\n\r\n**SvGrid Community gives you most of AG Grid Enterprise's features for\r\nfree**, and `@svgrid/enterprise` adds the export + print pack for ~40%\r\nless than AG Grid Enterprise. The trade-offs are Svelte-only and a much\r\nsmaller ecosystem.\r\n\r\n## Mental model - what changes\r\n\r\nAG Grid is one big object you configure declaratively. SvGrid is a\r\n**headless engine** (`createSvGrid`) with an optional **render\r\ncomponent** (`<SvGrid>`) on top - the same split TanStack Table made\r\npopular. You can use either layer; most projects use the render\r\ncomponent.\r\n\r\n```svelte\r\n<!-- AG Grid (via a Svelte wrapper) -->\r\n<AgGridSvelte\r\n gridOptions={{\r\n rowData: rows,\r\n columnDefs: columns,\r\n onGridReady: (params) => (gridApi = params.api),\r\n }}\r\n/>\r\n\r\n<!-- SvGrid -->\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n onApiReady={(api) => (gridApi = api)}\r\n/>\r\n```\r\n\r\nThree things to note:\r\n\r\n1. **No giant `gridOptions` blob.** Each capability is a top-level prop.\r\n2. **Features are opt-in.** You pass a `features` object built with\r\n `tableFeatures({...})` - only the features you list ship JS.\r\n3. **`onApiReady`** gives you a typed `SvGridApi` that is roughly the\r\n AG Grid `gridApi` equivalent (see the API-mapping table below).\r\n\r\n## Column definitions - direct translation\r\n\r\nThe shapes are similar enough that you can usually translate by hand\r\nwithout thinking too hard.\r\n\r\n```ts\r\n// AG Grid\r\nconst columnDefs: ColDef[] = [\r\n { field: 'name', headerName: 'Name', sortable: true, filter: true, width: 200 },\r\n { field: 'price', headerName: 'Price', type: 'numericColumn',\r\n valueFormatter: ({ value }) => `$${value.toFixed(2)}` },\r\n { field: 'date', headerName: 'Date',\r\n valueGetter: ({ data }) => new Date(data.date).toISOString().slice(0, 10) },\r\n { field: 'status', headerName: 'Status',\r\n cellRenderer: StatusCellRenderer,\r\n cellRendererParams: { onChange: handleStatusChange } },\r\n]\r\n```\r\n\r\n```ts\r\n// SvGrid\r\nimport { renderComponent, type ColumnDef } from '@svgrid/grid'\r\nimport StatusCell from './StatusCell.svelte'\r\n\r\nconst columns: ColumnDef<typeof features, Row>[] = [\r\n { field: 'name', header: 'Name', width: 200 }, // sortable + filterable by default\r\n { field: 'price', header: 'Price',\r\n format: { type: 'currency', currency: 'USD' } },\r\n { field: 'date', header: 'Date',\r\n format: { type: 'date', pattern: 'y-m-d' } },\r\n { field: 'status', header: 'Status',\r\n cell: renderComponent(StatusCell, (ctx) => ({\r\n value: ctx.getValue(),\r\n onChange: handleStatusChange,\r\n })),\r\n },\r\n]\r\n```\r\n\r\n### Property mapping\r\n\r\n| AG Grid | SvGrid | Notes |\r\n| --- | --- | --- |\r\n| `field` | `field` | Same. |\r\n| `headerName` | `header` | Accepts a string or a snippet/component. |\r\n| `width` | `width` | Same. |\r\n| `minWidth` / `maxWidth` | `minWidth` / `maxWidth` | Same. |\r\n| `sortable: true` | (default) | Sorting is on when `rowSortingFeature` is registered. |\r\n| `filter: true` | (default) | Filtering is on when `columnFilteringFeature` is registered. |\r\n| `valueFormatter` | `format: { ... }` | Built-in types: `number`, `currency`, `percent`, `date`. For custom, use `cell`. |\r\n| `valueGetter` | `fieldFn` | Returns the value for sorting/filtering. |\r\n| `cellRenderer` + `cellRendererParams` | `cell: renderComponent(C, ctx => props)` | One call, type-checked. |\r\n| `cellEditor: 'agTextCellEditor'` | `editorType: 'text'` | Built-in: `text`, `number`, `checkbox`, `date`. |\r\n| `editable: true` | `enableInlineEditing` prop on `<SvGrid>` | Per-grid, not per-column. (Per-column control on the roadmap.) |\r\n| `pinned: 'left'` / `'right'` | Right-click column menu → Pin | Set programmatically via the api. |\r\n| `rowGroup: true` | Via `setGroupBy([colId])` | See Grouping below. |\r\n| `aggFunc: 'sum'` | `aggregation: 'sum'` | Built-in: `sum`, `avg`, `min`, `max`, `count`. |\r\n\r\n## Feature registration - the one new thing\r\n\r\nAG Grid auto-enables most features; you turn them off. SvGrid is the\r\nopposite - features are opt-in. The result is a smaller bundle.\r\n\r\n```ts\r\nimport {\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n columnGroupingFeature,\r\n rowExpandingFeature,\r\n rowPaginationFeature,\r\n rowSelectionFeature,\r\n} from '@svgrid/grid'\r\n\r\nconst features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n // omit any you don't need - their code won't ship\r\n})\r\n```\r\n\r\nPass `features` to `<SvGrid>` once. From then on the grid behaves like\r\nAG Grid's `enableSorting`, `enableFilter`, `rowSelection`, etc. are all\r\non for the registered features.\r\n\r\n## API mapping (`gridApi` → `SvGridApi`)\r\n\r\nYou get the SvGrid API from `onApiReady` (equivalent to AG Grid's\r\n`onGridReady`).\r\n\r\n| AG Grid `gridApi.X()` | SvGrid `api.X()` |\r\n| --- | --- |\r\n| `setRowData(rows)` | (declarative - just update `data` prop) |\r\n| `addRow(row)` / `applyTransaction({ add: [row] })` | `api.addRow(row)` / `api.addRows(rows)` |\r\n| `applyTransaction({ remove: [row] })` | `api.removeRow(rowIndex)` |\r\n| `getValue(colId, rowNode)` | `api.getCellValue(rowIndex, columnId)` |\r\n| `setValue(...)` | `api.setCellValue(rowIndex, columnId, value)` |\r\n| `setColumnVisible(colId, visible)` | `api.setColumnVisible(columnId, visible)` |\r\n| `getSortModel()` / `setSortModel()` | `api.setSort(columnId, 'asc'\\|'desc'\\|null)` |\r\n| `setFilterModel({...})` | `api.setFilter(columnId, { operator, value })` |\r\n| `getDisplayedRowAtIndex(i)` / `forEachNodeAfterFilterAndSort(...)` | `api.getDisplayedRows()` |\r\n| `getModel()` (raw rows) | `api.getData()` |\r\n\r\n## Common patterns\r\n\r\n### Sorting + filtering + pagination (the 80% case)\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowPaginationFeature,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowPaginationFeature,\r\n })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination\r\n showColumnFilters\r\n/>\r\n```\r\n\r\nAG Grid equivalent: `gridOptions: { defaultColDef: { sortable: true, filter: true }, pagination: true }`.\r\n\r\n### Cell editing with persistence\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n function onCellValueChange(e: { rowIndex: number; columnId: string; value: unknown }) {\r\n // Persist however you like (fetch to backend, optimistic local update, etc.)\r\n console.log('cell changed', e)\r\n }\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n enableInlineEditing\r\n onCellValueChange={onCellValueChange}\r\n/>\r\n```\r\n\r\nAG Grid equivalent: `onCellValueChanged: ({ data, colDef, newValue, oldValue }) => ...`.\r\nSvGrid's event payload is column-id + row-index based rather than node-based; the row data is yours\r\nto mutate (or not) on the `rows` array you passed in.\r\n\r\n### Grouping + aggregation\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n columnGroupingFeature,\r\n rowSortingFeature,\r\n rowExpandingFeature,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnGroupingFeature,\r\n rowExpandingFeature,\r\n })\r\n\r\n const columns = [\r\n { field: 'department', header: 'Department' },\r\n { field: 'team', header: 'Team' },\r\n { field: 'salary', header: 'Salary', aggregation: 'sum',\r\n format: { type: 'currency', currency: 'USD' } },\r\n ]\r\n\r\n function setGroup(api) {\r\n api.setGroupBy(['department', 'team'])\r\n }\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showGroupingControls\r\n onApiReady={setGroup}\r\n/>\r\n```\r\n\r\nAG Grid Enterprise's `rowGroupPanelShow: 'always'` + `aggFunc: 'sum'` translates 1:1.\r\n\r\n### Master / detail\r\n\r\nAG Grid Enterprise feature; **free in SvGrid Community**. See\r\n[demo 08](https://svgrid.com/demos/08-tree-and-master-detail/) for the exact pattern.\r\n\r\n### Server-side data\r\n\r\nAG Grid uses an `IServerSideDatasource` interface. SvGrid uses\r\n`externalSort` + `externalFilter` props - your code keeps full control\r\nover the query, and the grid records UI state but doesn't re-order rows\r\nlocally. See [demo 09](https://svgrid.com/demos/09-server-side/).\r\n\r\n### Excel / PDF export\r\n\r\nAG Grid: `gridApi.exportDataAsExcel({...})` (Enterprise-only).\r\nSvGrid: install `@svgrid/enterprise`, call `api.exportData({ format: 'xlsx', ... })`. See [Data export and printing](./export.md).\r\n\r\n```ts\r\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\r\nsetLicenseKey('SVENTERPRISE-...') // your Enterprise key\r\n\r\n// inside onApiReady:\r\nconst pro = installEnterprise(api)\r\nawait pro.exportData({ format: 'xlsx', filename: 'orders' })\r\n```\r\n\r\n## Gotchas - things that don't translate directly\r\n\r\n### 1. Per-column `editable: true`\r\nSvGrid v1.0 toggles editing at the grid level (`enableInlineEditing`).\r\nPer-column editability is on the roadmap - until then, you can gate\r\nedits in your `onCellValueChange` handler.\r\n\r\n### 2. Column drag-to-reorder\r\nSvGrid v1.0 supports column reorder via the API (`setColumnOrder`), not\r\nheader drag. Built-in header drag is on the roadmap. Most teams don't\r\nmiss it - it's a power-user feature.\r\n\r\n### 3. AG Grid `valueGetter` chains\r\nAG Grid's `valueGetter` can read other column values via the API. In\r\nSvGrid, `fieldFn` only receives the row; if you need cross-column\r\ncomputed values, do it in the cell renderer with `ctx.row.original` or\r\ncompute the derived value upstream and store it in the row.\r\n\r\n### 4. `cellClass` / `rowClass` callbacks\r\nOn the roadmap. For now, render a wrapper element in your `cell` snippet\r\nwith the conditional class.\r\n\r\n### 5. The Status Bar / Side Bar / Tool Panels\r\nAG Grid's chrome (status bar with row count, side bar with filters and\r\ncolumns panels) doesn't exist in SvGrid - build it as plain Svelte\r\nmarkup around the grid. Most teams build their own anyway because\r\nAG Grid's defaults rarely match a polished design system.\r\n\r\n### 6. Set filter (the Excel-style funnel popup)\r\nSvGrid ships an Excel-style filter menu (free in Community). API surface\r\nis similar but not identical - see [Set filter](./filtering/set-filter.md).\r\n\r\n## When NOT to migrate\r\n\r\nBe honest. Stay on AG Grid if you:\r\n\r\n- **Use multiple frameworks** - AG Grid has React, Angular, Vue, Solid, Qwik, vanilla adapters. SvGrid is Svelte-only.\r\n- **Use AG Grid's integrated charts** - those depend on AG Grid's chart engine; SvGrid has no equivalent.\r\n- **Depend on AG Grid pivoting** - not in SvGrid's roadmap.\r\n- **Are mid-project and shipping in <2 weeks** - the migration is a few hours per grid, but only do it when you have buffer.\r\n- **Have a Svelte 4 codebase you can't upgrade** - SvGrid requires Svelte 5 runes. (Consider [htmlelements.com](https://www.htmlelements.com) for vanilla / multi-framework.)\r\n\r\nIf none of those apply: switching saves you $400-$1000 per dev per year,\r\ncuts your bundle by 200+ KB, and gives you a Svelte-native API that\r\nplays well with runes.\r\n\r\n## Step-by-step migration\r\n\r\nA typical migration of a single grid takes 1-3 hours:\r\n\r\n1. **Install** - `pnpm add @svgrid/grid` (and `@svgrid/enterprise` if you need export).\r\n2. **Translate columnDefs** - use the mapping table above. Most columns are 1:1.\r\n3. **Wrap features** - figure out which AG Grid features you actually use; register only those in `tableFeatures({...})`.\r\n4. **Swap the component** - `<AgGridSvelte gridOptions={...}>` → `<SvGrid data={rows} columns={columns} features={features}>`.\r\n5. **Move event handlers** - AG Grid `onCellValueChanged` → SvGrid `onCellValueChange` (signature differs slightly, see above).\r\n6. **Move API calls** - AG Grid `gridApi.X()` → SvGrid `api.X()` per the API table.\r\n7. **Test interactions** - sort, filter, edit, select. Most \"just works.\"\r\n8. **Remove `ag-grid-*` packages** - `pnpm remove ag-grid-community ag-grid-svelte` etc. Inspect your bundle to confirm the 200+ KB drop.\r\n\r\n## Need help migrating?\r\n\r\nEnterprise customers get **migration help included** with the support plan\r\n(architecture review, port one grid for you as a reference). Email\r\n`support@jqwidgets.com` after purchase, or `sales@jqwidgets.com` for\r\npre-sales questions.\r\n\r\n## See also\r\n\r\n- [Getting started](../getting-started.md) - full SvGrid walkthrough\r\n- [Why headless?](../why-headless.md) - the headless / render-component split\r\n- [Data export and printing](./export.md) - the `@svgrid/enterprise` feature pack\r\n- [SvGrid vs AG Grid comparison page](https://svgrid.com/compare/ag-grid/)\r\n\r\n## Frequently asked questions\r\n\r\n### Is SvGrid a drop-in replacement for AG Grid in Svelte?\r\n\r\nNot a literal drop-in - there is no `ag-grid-svelte` shim to swap. But the\r\nconcepts map closely: column definitions, row models, sorting, filtering,\r\ngrouping, and an imperative API all have direct SvGrid equivalents, so most\r\nteams port a grid in 30 minutes to a day. It is a configuration translation,\r\nnot a rewrite.\r\n\r\n### What is the SvGrid equivalent of AG Grid Enterprise?\r\n\r\n`@svgrid/enterprise`. It adds Excel/PDF/CSV/TSV/HTML export, a printable view, pivot\r\ntables, data import, and AI helpers. It is licensed per developer\r\n($599 single-app / $999 multi-app), not per deployment, and the Community\r\npackage is MIT-licensed and free for commercial use.\r\n\r\n### Does SvGrid use Svelte 5 runes, or is it a wrapper?\r\n\r\nIt is Svelte-5-native. State is `$state` / `$derived` / `$effect` and cells\r\nrender through Svelte snippets - there is no React or Angular core underneath\r\nand no framework bridge to keep in sync.\r\n\r\n### Will my AG Grid bundle size shrink?\r\n\r\nAlmost always. SvGrid's full render component is ~80 KB gzipped (or ~2 KB\r\nfor the headless core) versus a much heavier AG Grid Community bundle, and you\r\nonly add `@svgrid/enterprise` features you actually use - so you ship a fraction of\r\nthe JavaScript.\r\n"
3485
+ "markdown": "# Migrating from AG Grid to SvGrid\r\n\r\nIf you tried AG Grid on a Svelte 5 project - via `ag-grid-svelte`, the\r\nold `ag-grid-community/svelte`, or a hand-rolled wrapper - you probably\r\nhit the same friction everyone hits: the bridge between AG Grid's\r\nReact/Angular-first API and Svelte 5 runes is brittle, the bundle is\r\nheavy, and the Enterprise pricing only makes sense at scale.\r\n\r\nThis page is a 30-minute migration recipe from AG Grid to SvGrid. It\r\ncovers what maps 1:1, what's different by design, and what you'll lose.\r\nWe tell you when **not** to switch at the bottom.\r\n\r\n## TL;DR\r\n\r\n| | AG Grid Community | AG Grid Enterprise | SvGrid Community | @svgrid/enterprise |\r\n| --- | --- | --- | --- | --- |\r\n| **License** | MIT | Commercial (~$999/dev/yr) | **MIT** | $599/dev/yr (single app) or $999/dev/yr (multi app) |\r\n| **Svelte 5 native** | āŒ (wrapper) | āŒ (wrapper) | āœ… | āœ… |\r\n| **Bundle (gzipped)** | ~250 KB | ~400 KB | ~2 KB headless / ~80 KB full | lazy-loaded subpaths |\r\n| **Sorting / filtering / grouping** | āœ… | āœ… | āœ… | (in Community) |\r\n| **Master/detail, tree, range select** | āŒ Enterprise only | āœ… | āœ… (free) | (in Community) |\r\n| **Excel export** | āŒ | āœ… Enterprise | āŒ | āœ… |\r\n| **PDF / CSV / TSV / HTML export** | āŒ | Partial | āŒ | āœ… |\r\n| **Print view** | āŒ | āŒ | āŒ | āœ… |\r\n| **Set filter / Excel-style filter menu** | āŒ Enterprise | āœ… | āœ… (free) | (in Community) |\r\n\r\n**SvGrid Community gives you most of AG Grid Enterprise's features for\r\nfree**, and `@svgrid/enterprise` adds the export + print pack for ~40%\r\nless than AG Grid Enterprise. The trade-offs are Svelte-only and a much\r\nsmaller ecosystem.\r\n\r\n## Mental model - what changes\r\n\r\nAG Grid is one big object you configure declaratively. SvGrid is a\r\n**headless engine** (`createSvGrid`) with an optional **render\r\ncomponent** (`<SvGrid>`) on top - the same split TanStack Table made\r\npopular. You can use either layer; most projects use the render\r\ncomponent.\r\n\r\n```svelte\r\n<!-- AG Grid (via a Svelte wrapper) -->\r\n<AgGridSvelte\r\n gridOptions={{\r\n rowData: rows,\r\n columnDefs: columns,\r\n onGridReady: (params) => (gridApi = params.api),\r\n }}\r\n/>\r\n\r\n<!-- SvGrid -->\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n onApiReady={(api) => (gridApi = api)}\r\n/>\r\n```\r\n\r\nThree things to note:\r\n\r\n1. **No giant `gridOptions` blob.** Each capability is a top-level prop.\r\n2. **Features are opt-in.** You pass a `features` object built with\r\n `tableFeatures({...})` - only the features you list ship JS.\r\n3. **`onApiReady`** gives you a typed `SvGridApi` that is roughly the\r\n AG Grid `gridApi` equivalent (see the API-mapping table below).\r\n\r\n## Column definitions - direct translation\r\n\r\nThe shapes are similar enough that you can usually translate by hand\r\nwithout thinking too hard.\r\n\r\n```ts\r\n// AG Grid\r\nconst columnDefs: ColDef[] = [\r\n { field: 'name', headerName: 'Name', sortable: true, filter: true, width: 200 },\r\n { field: 'price', headerName: 'Price', type: 'numericColumn',\r\n valueFormatter: ({ value }) => `$${value.toFixed(2)}` },\r\n { field: 'date', headerName: 'Date',\r\n valueGetter: ({ data }) => new Date(data.date).toISOString().slice(0, 10) },\r\n { field: 'status', headerName: 'Status',\r\n cellRenderer: StatusCellRenderer,\r\n cellRendererParams: { onChange: handleStatusChange } },\r\n]\r\n```\r\n\r\n```ts\r\n// SvGrid\r\nimport { renderComponent, type ColumnDef } from '@svgrid/grid'\r\nimport StatusCell from './StatusCell.svelte'\r\n\r\nconst columns: ColumnDef<typeof features, Row>[] = [\r\n { field: 'name', header: 'Name', width: 200 }, // sortable + filterable by default\r\n { field: 'price', header: 'Price',\r\n format: { type: 'currency', currency: 'USD' } },\r\n { field: 'date', header: 'Date',\r\n format: { type: 'date', pattern: 'y-m-d' } },\r\n { field: 'status', header: 'Status',\r\n cell: renderComponent(StatusCell, (ctx) => ({\r\n value: ctx.getValue(),\r\n onChange: handleStatusChange,\r\n })),\r\n },\r\n]\r\n```\r\n\r\n### Property mapping\r\n\r\n| AG Grid | SvGrid | Notes |\r\n| --- | --- | --- |\r\n| `field` | `field` | Same. |\r\n| `headerName` | `header` | Accepts a string or a snippet/component. |\r\n| `width` | `width` | Same. |\r\n| `minWidth` / `maxWidth` | `minWidth` / `maxWidth` | Same. |\r\n| `sortable: true` | (default) | Sorting is on when `rowSortingFeature` is registered. |\r\n| `filter: true` | (default) | Filtering is on when `columnFilteringFeature` is registered. |\r\n| `valueFormatter` | `format: { ... }` | Built-in types: `number`, `currency`, `percent`, `date`. For custom, use `cell`. |\r\n| `valueGetter` | `fieldFn` | Returns the value for sorting/filtering. |\r\n| `cellRenderer` + `cellRendererParams` | `cell: renderComponent(C, ctx => props)` | One call, type-checked. |\r\n| `cellEditor: 'agTextCellEditor'` | `editorType: 'text'` | Built-in: `text`, `number`, `checkbox`, `date`. |\r\n| `editable: true` | `enableInlineEditing` prop on `<SvGrid>` | Per-grid, not per-column. (Per-column control on the roadmap.) |\r\n| `pinned: 'left'` / `'right'` | Right-click column menu → Pin | Set programmatically via the api. |\r\n| `rowGroup: true` | Via `setGroupBy([colId])` | See Grouping below. |\r\n| `aggFunc: 'sum'` | `aggregation: 'sum'` | Built-in: `sum`, `avg`, `min`, `max`, `count`. |\r\n\r\n## Feature registration - the one new thing\r\n\r\nAG Grid auto-enables most features; you turn them off. SvGrid is the\r\nopposite - features are opt-in. The result is a smaller bundle.\r\n\r\n```ts\r\nimport {\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n columnGroupingFeature,\r\n rowExpandingFeature,\r\n rowPaginationFeature,\r\n rowSelectionFeature,\r\n} from '@svgrid/grid'\r\n\r\nconst features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowSelectionFeature,\r\n // omit any you don't need - their code won't ship\r\n})\r\n```\r\n\r\nPass `features` to `<SvGrid>` once. From then on the grid behaves like\r\nAG Grid's `enableSorting`, `enableFilter`, `rowSelection`, etc. are all\r\non for the registered features.\r\n\r\n## API mapping (`gridApi` → `SvGridApi`)\r\n\r\nYou get the SvGrid API from `onApiReady` (equivalent to AG Grid's\r\n`onGridReady`).\r\n\r\n| AG Grid `gridApi.X()` | SvGrid `api.X()` |\r\n| --- | --- |\r\n| `setRowData(rows)` | (declarative - just update `data` prop) |\r\n| `addRow(row)` / `applyTransaction({ add: [row] })` | `api.addRow(row)` / `api.addRows(rows)` |\r\n| `applyTransaction({ remove: [row] })` | `api.removeRow(rowIndex)` |\r\n| `getValue(colId, rowNode)` | `api.getCellValue(rowIndex, columnId)` |\r\n| `setValue(...)` | `api.setCellValue(rowIndex, columnId, value)` |\r\n| `setColumnVisible(colId, visible)` | `api.setColumnVisible(columnId, visible)` |\r\n| `getSortModel()` / `setSortModel()` | `api.setSort(columnId, 'asc'\\|'desc'\\|null)` |\r\n| `setFilterModel({...})` | `api.setFilter(columnId, { operator, value })` |\r\n| `getDisplayedRowAtIndex(i)` / `forEachNodeAfterFilterAndSort(...)` | `api.getDisplayedRows()` |\r\n| `getModel()` (raw rows) | `api.getData()` |\r\n\r\n## Common patterns\r\n\r\n### Sorting + filtering + pagination (the 80% case)\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowPaginationFeature,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnFilteringFeature,\r\n rowPaginationFeature,\r\n })\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showPagination\r\n showColumnFilters\r\n/>\r\n```\r\n\r\nAG Grid equivalent: `gridOptions: { defaultColDef: { sortable: true, filter: true }, pagination: true }`.\r\n\r\n### Cell editing with persistence\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n function onCellValueChange(e: { rowIndex: number; columnId: string; value: unknown }) {\r\n // Persist however you like (fetch to backend, optimistic local update, etc.)\r\n console.log('cell changed', e)\r\n }\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n enableInlineEditing\r\n onCellValueChange={onCellValueChange}\r\n/>\r\n```\r\n\r\nAG Grid equivalent: `onCellValueChanged: ({ data, colDef, newValue, oldValue }) => ...`.\r\nSvGrid's event payload is column-id + row-index based rather than node-based; the row data is yours\r\nto mutate (or not) on the `rows` array you passed in.\r\n\r\n### Grouping + aggregation\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import {\r\n SvGrid,\r\n tableFeatures,\r\n columnGroupingFeature,\r\n rowSortingFeature,\r\n rowExpandingFeature,\r\n } from '@svgrid/grid'\r\n\r\n const features = tableFeatures({\r\n rowSortingFeature,\r\n columnGroupingFeature,\r\n rowExpandingFeature,\r\n })\r\n\r\n const columns = [\r\n { field: 'department', header: 'Department' },\r\n { field: 'team', header: 'Team' },\r\n { field: 'salary', header: 'Salary', aggregation: 'sum',\r\n format: { type: 'currency', currency: 'USD' } },\r\n ]\r\n\r\n function setGroup(api) {\r\n api.setGroupBy(['department', 'team'])\r\n }\r\n</script>\r\n\r\n<SvGrid\r\n data={rows}\r\n columns={columns}\r\n features={features}\r\n showGroupingControls\r\n onApiReady={setGroup}\r\n/>\r\n```\r\n\r\nAG Grid Enterprise's `rowGroupPanelShow: 'always'` + `aggFunc: 'sum'` translates 1:1.\r\n\r\n### Master / detail\r\n\r\nAG Grid Enterprise feature; **free in SvGrid Community**. See\r\n[demo 08](https://svgrid.com/demos/08-tree-and-master-detail/) for the exact pattern.\r\n\r\n### Server-side data\r\n\r\nAG Grid uses an `IServerSideDatasource` interface. SvGrid uses\r\n`externalSort` + `externalFilter` props - your code keeps full control\r\nover the query, and the grid records UI state but doesn't re-order rows\r\nlocally. See [demo 09](https://svgrid.com/demos/09-server-side/).\r\n\r\n### Excel / PDF export\r\n\r\nAG Grid: `gridApi.exportDataAsExcel({...})` (Enterprise-only).\r\nSvGrid: install `@svgrid/enterprise`, call `api.exportData({ format: 'xlsx', ... })`. See [Data export and printing](./export.md).\r\n\r\n```ts\r\nimport { installEnterprise, setLicenseKey } from '@svgrid/enterprise'\r\nsetLicenseKey('SVENTERPRISE-...') // your Enterprise key\r\n\r\n// inside onApiReady:\r\nconst pro = installEnterprise(api)\r\nawait pro.exportData({ format: 'xlsx', filename: 'orders' })\r\n```\r\n\r\n## Gotchas - things that don't translate directly\r\n\r\n### 1. Per-column `editable: true`\r\nSvGrid v1.0 toggles editing at the grid level (`enableInlineEditing`).\r\nPer-column editability is on the roadmap - until then, you can gate\r\nedits in your `onCellValueChange` handler.\r\n\r\n### 2. Column drag-to-reorder\r\nSvGrid v1.0 supports column reorder via the API (`setColumnOrder`), not\r\nheader drag. Built-in header drag is on the roadmap. Most teams don't\r\nmiss it - it's a power-user feature.\r\n\r\n### 3. AG Grid `valueGetter` chains\r\nAG Grid's `valueGetter` can read other column values via the API. In\r\nSvGrid, `fieldFn` only receives the row; if you need cross-column\r\ncomputed values, do it in the cell renderer with `ctx.row.original` or\r\ncompute the derived value upstream and store it in the row.\r\n\r\n### 4. `cellClass` / `rowClass` callbacks\r\nOn the roadmap. For now, render a wrapper element in your `cell` snippet\r\nwith the conditional class.\r\n\r\n### 5. The Status Bar / Side Bar / Tool Panels\r\nAG Grid's chrome (status bar with row count, side bar with filters and\r\ncolumns panels) doesn't exist in SvGrid - build it as plain Svelte\r\nmarkup around the grid. Most teams build their own anyway because\r\nAG Grid's defaults rarely match a polished design system.\r\n\r\n### 6. Set filter (the Excel-style funnel popup)\r\nSvGrid ships an Excel-style filter menu (free in Community). API surface\r\nis similar but not identical - see [Set filter](./filtering/set-filter.md).\r\n\r\n## When NOT to migrate\r\n\r\nBe honest. Stay on AG Grid if you:\r\n\r\n- **Use multiple frameworks** - AG Grid has React, Angular, Vue, Solid, Qwik, vanilla adapters. SvGrid is Svelte-only.\r\n- **Use AG Grid's integrated charts** - those depend on AG Grid's chart engine; SvGrid has no equivalent.\r\n- **Depend on AG Grid pivoting** - not in SvGrid's roadmap.\r\n- **Are mid-project and shipping in <2 weeks** - the migration is a few hours per grid, but only do it when you have buffer.\r\n- **Have a Svelte 4 codebase you can't upgrade** - SvGrid requires Svelte 5 runes. (Consider [htmlelements.com](https://www.htmlelements.com) for vanilla / multi-framework.)\r\n\r\nIf none of those apply: switching saves you $400-$1000 per dev per year,\r\ncuts your bundle by 200+ KB, and gives you a Svelte-native API that\r\nplays well with runes.\r\n\r\n## Step-by-step migration\r\n\r\nA typical migration of a single grid takes 1-3 hours:\r\n\r\n1. **Install** - `pnpm add @svgrid/grid` (and `@svgrid/enterprise` if you need export).\r\n2. **Translate columnDefs** - use the mapping table above. Most columns are 1:1.\r\n3. **Wrap features** - figure out which AG Grid features you actually use; register only those in `tableFeatures({...})`.\r\n4. **Swap the component** - `<AgGridSvelte gridOptions={...}>` → `<SvGrid data={rows} columns={columns} features={features}>`.\r\n5. **Move event handlers** - AG Grid `onCellValueChanged` → SvGrid `onCellValueChange` (signature differs slightly, see above).\r\n6. **Move API calls** - AG Grid `gridApi.X()` → SvGrid `api.X()` per the API table.\r\n7. **Test interactions** - sort, filter, edit, select. Most \"just works.\"\r\n8. **Remove `ag-grid-*` packages** - `pnpm remove ag-grid-community ag-grid-svelte` etc. Inspect your bundle to confirm the 200+ KB drop.\r\n\r\n## Need help migrating?\r\n\r\nEnterprise customers get **migration help included** with the support plan\r\n(architecture review, port one grid for you as a reference). Email\r\n`support@jqwidgets.com` after purchase, or `sales@jqwidgets.com` for\r\npre-sales questions.\r\n\r\n## See also\r\n\r\n- [Getting started](../getting-started.md) - full SvGrid walkthrough\r\n- [Why headless?](../why-headless.md) - the headless / render-component split\r\n- [Data export and printing](./export.md) - the `@svgrid/enterprise` feature pack\r\n- [SvGrid vs AG Grid comparison page](https://svgrid.com/compare/ag-grid/)\r\n\r\n## Frequently asked questions\r\n\r\n### Is SvGrid a drop-in replacement for AG Grid in Svelte?\r\n\r\nNot a literal drop-in - there is no `ag-grid-svelte` shim to swap. But the\r\nconcepts map closely: column definitions, row models, sorting, filtering,\r\ngrouping, and an imperative API all have direct SvGrid equivalents, so most\r\nteams port a grid in 30 minutes to a day. It is a configuration translation,\r\nnot a rewrite.\r\n\r\n### What is the SvGrid equivalent of AG Grid Enterprise?\r\n\r\n`@svgrid/enterprise`. It adds Excel/PDF/CSV/TSV/HTML export, a printable view, pivot\r\ntables, data import, and AI helpers. It is licensed per developer\r\n($599 single-app / $999 multi-app), not per deployment, and the Community\r\npackage is MIT-licensed and free for commercial use.\r\n\r\n### Does SvGrid use Svelte 5 runes, or is it a wrapper?\r\n\r\nIt is Svelte-5-native. State is `$state` / `$derived` / `$effect` and cells\r\nrender through Svelte snippets - there is no React or Angular core underneath\r\nand no framework bridge to keep in sync.\r\n\r\n### Will my AG Grid bundle size shrink?\r\n\r\nAlmost always. SvGrid's full render component is ~80 KB gzipped (or ~2 KB\r\nfor the headless core) versus a much heavier AG Grid Community bundle, and you\r\nonly add `@svgrid/enterprise` features you actually use - so you ship a fraction of\r\nthe JavaScript.\r\n"
3465
3486
  },
3466
3487
  {
3467
3488
  "slug": "help/migrating-from-devextreme",
@@ -3569,7 +3590,7 @@ export const docs = [
3569
3590
  "slug": "help/missing-features",
3570
3591
  "path": "docs/help/missing-features.md",
3571
3592
  "title": "Missing features",
3572
- "markdown": "# Missing features\r\n\r\nAn honest accounting of what is **not yet built**, audited against the shipped\r\ndemo catalog. Most of what used to live here has shipped; the remaining gaps\r\nare small and clearly marked. Each entry has a rough effort estimate (S / M / L).\r\n\r\nShipped items are struck through with the demo or API that covers them, so you\r\ncan see both the trajectory and the (short) list of real gaps.\r\n\r\n## Columns\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~`getRowId` prop~~ | **shipped** | āœ“ |\r\n| ~~`cellClass(ctx)` / `rowClass(ctx)` callbacks~~ | **shipped** | āœ“ |\r\n| ~~`getColumnWidths()` / `setColumnWidth()`~~ | **shipped** | āœ“ |\r\n| ~~`setColumnPinning()` / `getColumnPinning()`~~ | **shipped** | āœ“ |\r\n| ~~Header drag-to-reorder~~ | **shipped** - `enableColumnReorder`; demo `109-column-reorder-engine` | āœ“ |\r\n| ~~Per-column disable sort / filter~~ | **shipped** - `sortable` / `filterable` on `ColumnDef` | āœ“ |\r\n| ~~Column spanning~~ | **shipped** - cell merging via `MergeSpec` + `spreadsheetLayout` (demo `170`), **plus** declarative value-driven `colSpan` / `rowSpan` via `spansToMerges` | āœ“ |\r\n\r\n## Rows\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~Row pinning (top / bottom)~~ | **shipped** - `pinnedTopRows` / `pinnedBottomRows`; demos `107-pinned-rows`, `108-pinned-rows-engine` | āœ“ |\r\n| ~~Row spanning (merged cells across rows)~~ | **shipped as cell merging** - `rowspan` in `MergeSpec`; demo `170-cell-merging` | āœ“ |\r\n| ~~Full-width / detail row API~~ | **shipped** - `isDetailRow`; demo `106-detail-rows` | āœ“ |\r\n| ~~Variable row height with `<SvGrid>`~~ | **shipped** - `rowHeight` accepts `(rowIndex) => px` | āœ“ |\r\n| ~~`api.getDisplayedRows()`~~ | **shipped** | āœ“ |\r\n| ~~Built-in row dragging~~ | **shipped** - `rowDragManaged` reorders in-grid and moves rows **grid-to-grid** via a shared `rowDragGroup`; `onRowDragEnd` on the receiver; demos `105-row-reorder` (custom) + `180-row-dragging` (managed) | āœ“ |\r\n\r\n## Cells\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~Built-in tooltip API on `ColumnDef`~~ | **shipped** - `tooltip`; demo `85-tooltips-and-notes` | āœ“ |\r\n| ~~Formula language / formula editor~~ | **shipped** - in-grid engine (demo `83-spreadsheet-formulas`), HyperFormula adapter (demo `173-hyperformula`), xlsx formulas (`101`, `119`) | āœ“ |\r\n| ~~Find-in-grid~~ | **shipped** - Ctrl+F; demo `87-find-in-grid` | āœ“ |\r\n| ~~Notes~~ | **shipped** - `notes` prop + cell comments; demos `85-tooltips-and-notes`, `91-cell-comments` | āœ“ |\r\n| ~~Built-in cell flash / animated change highlight~~ | **shipped** - `cellFlash` on `ColumnDef` | āœ“ |\r\n\r\n## Export / Print\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~Excel / xlsx, PDF, CSV / TSV / HTML export, Print~~ | **shipped** in `@svgrid/enterprise` - demos `21`, `56`-`59`, `93`, `101`, `119`, `126`, `127` | āœ“ |\r\n\r\n## Filtering\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~`between` operator in the column menu~~ | **shipped** - demo `64-filter-between-operator` | āœ“ |\r\n| ~~Set filter (tree-list, async, Excel-mode)~~ | **shipped** - demo `111-set-filter-advanced` | āœ“ |\r\n| ~~Locale-aware text filtering~~ | **shipped** - demo `110-locale-aware-filter` | āœ“ |\r\n| ~~`clearAllFilters()` / `getFilters()`~~ | **shipped** | āœ“ |\r\n| ~~Floating filters (per-operator)~~ | **shipped** - filter row honours every operator per column with typed inputs + inline `between`; demo `179` | āœ“ |\r\n| ~~Multi-condition filter within one column (AND / OR)~~ | **shipped** - two conditions per column via the funnel or `api.setFilter`; demo `178` | āœ“ |\r\n\r\n## Editing\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~`cellEditor` slot for custom inline editors~~ | **shipped** - demos `84-editor-types`, `66-custom-cell-editors` | āœ“ |\r\n| ~~Built-in select & rich-select editors~~ | **shipped** - `editorType: 'list' / 'rich-select'`; demo `84-editor-types` | āœ“ |\r\n| ~~Built-in large-text (textarea) editor~~ | **shipped** - demo `84-editor-types` | āœ“ |\r\n| ~~Per-column `validate()`~~ | **shipped** - demos `24-validation`, `103-async-validation` | āœ“ |\r\n| ~~Built-in undo / redo stack~~ | **shipped** - `api.undo()` / `redo()`; demo `86-undo-redo` | āœ“ |\r\n| ~~Batch / staged editing mode~~ | **shipped** - demo `88-staged-editing` | āœ“ |\r\n| ~~Per-column `valueParser`~~ | **shipped** - `valueParser` on `ColumnDef`; demo `175` | āœ“ |\r\n| ~~Programmatic `api.startEditing()` / `stopEditing()`~~ | **shipped** - demo `176` | āœ“ |\r\n| ~~Full-row editing mode~~ | **shipped** - `fullRowEditing`; demo `177` | āœ“ |\r\n\r\n## The real remaining gaps (short list)\r\n\r\nThe previous round shipped declarative col/row spanning, cell flash,\r\n`valueParser`, programmatic start/stop editing, full-row editing, multi-condition\r\nfilters, per-operator floating filters, and managed grid-to-grid row dragging -\r\nall with demos and docs. What is left is a short list of AG-Grid-Enterprise\r\nparity items, mostly UX affordances on top of engines that already exist:\r\n\r\nAudited against the code and the 171-demo catalog (four-way inventory, June 2026).\r\nThis list is deliberately short - most AG-Grid-Enterprise parity items already\r\nship (row-group panel `89`, status bar `144`, tool panel `146`, pivot + designer,\r\nserver-side row model `148`, export with images/styles `56`/`58`, charts,\r\nsparklines, collaboration). The genuine remaining gaps:\r\n\r\n| Gap | What exists today | Effort |\r\n| --- | ----------------- | ------ |\r\n| ~~**Multiple range selection** (Ctrl-drag additional cell ranges)~~ | **shipped** - Ctrl/Cmd+drag adds ranges; all highlight + copy together; `api.selectCells([...])` takes many; demo `118` | āœ“ |\r\n| ~~**Cell data-type inference** (`cellDataType`)~~ | **shipped** - `cellDataType` on `ColumnDef` + grid-level `inferColumnTypes` | āœ“ |\r\n| ~~**Merged-cell export to xlsx**~~ | **shipped** - `merges` option on `exportData` (single-sheet), lines up with `MergeSpec` | āœ“ |\r\n| ~~**Filters tool panel tab**~~ | **shipped** - Columns \\| Filters tabs in the tool panel (`146`), in sync with the column menu | āœ“ |\r\n| ~~**Copy with headers**~~ | **shipped** - `copyHeadersToClipboard` + `processCellForClipboard` hook | āœ“ |\r\n| ~~**Aligned grids**~~ | **shipped** - `alignedGridGroup` syncs horizontal scroll + column-resize widths; demo `182` | āœ“ |\r\n| ~~**Collapsible column groups**~~ | **shipped** - `columnGroupShow: 'open' \\| 'closed'` + `openByDefault`; demo `183` | āœ“ |\r\n| ~~**Column menu tabs** (General / Filter / Columns)~~ | **shipped** - tabbed column menu; demo any filterable grid | āœ“ |\r\n| ~~**External row-drag drop zones**~~ | **shipped** - `rowDropZone` action (drop rows onto any element); demo `184` | āœ“ |\r\n| ~~**Nested master/detail grids**~~ | **shipped** - `isDetailRow` + `renderDetailRow` hosting a child grid; demo `181` | āœ“ |\r\n\r\n### Still open (medium / large)\r\n\r\n| Gap | Note | Effort |\r\n| --- | ---- | ------ |\r\n| **Multi Filter** (set + text stacked on one column) | one operator-set per column today | M |\r\n| **Custom filter / floating-filter component** slot | first-class pluggable filter | M |\r\n| **Custom tool panels** | panel is fixed Columns + Filters | M |\r\n| **UI-string localisation** (`localeText`) | menu/panel labels are EN; data formatting is locale-aware | M |\r\n| **Row-grouping display modes** (single / multiple / groupRows) + group-level footers | grouping + group panel ship; display variants do not | M |\r\n| **In-grid pivot mode** (toggle on the main grid) | pivot ships as engine + designer, rendered as a separate grid | M-L |\r\n| **Integrated-chart depth** (range-chart context menu, chart toolbar, cross-filtering) | 17 chart types + wizard ship; the select-range-to-chart loop does not | L |\r\n| **Server-side pivot / viewport row model** | SSRM ships sort/filter/group/infinite | L |\r\n\r\n## What's already there\r\n\r\nThe stable, built-in feature surface is large. Highlights: sorting (single +\r\nmulti), per-column filtering (menu + row + global) with a `between` range\r\noperator and set/tree/async filters, pagination, grouping + aggregation, tree\r\ndata, master/detail + full-width detail rows, row + column virtualization\r\n(100k+ and a 1M-row demo), cell-range selection + copy/paste + Excel-style fill\r\nhandle, inline editing with 14 editor types plus a custom `cellEditor` slot,\r\nundo/redo, staged editing, find-in-grid, notes + cell comments, tooltips,\r\nconditional formatting, sparklines, cell merging, column pinning/reorder/resize,\r\nrow pinning, a formula engine (+ HyperFormula adapter), server-side row model,\r\nExcel/PDF/CSV/HTML export + print (Enterprise), pivot + charts + AI (Enterprise),\r\nWAI-ARIA + keyboard nav, RTL, i18n, theming via `--sg-*` tokens, SSR, and a\r\nCSP-clean runtime.\r\n\r\n## How to contribute\r\n\r\n1. Pick a gap from **The real remaining gaps** above.\r\n2. Open an issue describing the API you'd want - names, types, the minimal change.\r\n3. If you can write the patch, do so, and keep tests with the change.\r\n"
3593
+ "markdown": "# Missing features\r\n\r\nAn honest accounting of what is **not yet built**, audited against the shipped\r\ndemo catalog. Most of what used to live here has shipped; the remaining gaps\r\nare small and clearly marked. Each entry has a rough effort estimate (S / M / L).\r\n\r\nShipped items are struck through with the demo or API that covers them, so you\r\ncan see both the trajectory and the (short) list of real gaps.\r\n\r\n## Columns\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~`getRowId` prop~~ | **shipped** | āœ“ |\r\n| ~~`cellClass(ctx)` / `rowClass(ctx)` callbacks~~ | **shipped** | āœ“ |\r\n| ~~`getColumnWidths()` / `setColumnWidth()`~~ | **shipped** | āœ“ |\r\n| ~~`setColumnPinning()` / `getColumnPinning()`~~ | **shipped** | āœ“ |\r\n| ~~Header drag-to-reorder~~ | **shipped** - `enableColumnReorder`; demo `109-column-reorder-engine` | āœ“ |\r\n| ~~Per-column disable sort / filter~~ | **shipped** - `sortable` / `filterable` on `ColumnDef` | āœ“ |\r\n| ~~Column spanning~~ | **shipped** - cell merging via `MergeSpec` + `spreadsheetLayout` (demo `170`), **plus** declarative value-driven `colSpan` / `rowSpan` via `spansToMerges` | āœ“ |\r\n\r\n## Rows\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~Row pinning (top / bottom)~~ | **shipped** - `pinnedTopRows` / `pinnedBottomRows`; demos `107-pinned-rows`, `108-pinned-rows-engine` | āœ“ |\r\n| ~~Row spanning (merged cells across rows)~~ | **shipped as cell merging** - `rowspan` in `MergeSpec`; demo `170-cell-merging` | āœ“ |\r\n| ~~Full-width / detail row API~~ | **shipped** - `isDetailRow`; demo `106-detail-rows` | āœ“ |\r\n| ~~Variable row height with `<SvGrid>`~~ | **shipped** - `rowHeight` accepts `(rowIndex) => px` | āœ“ |\r\n| ~~Auto row height (measure content)~~ | **shipped** - `autoRowHeight` wraps cell text and measures each row, virtualization included | āœ“ |\r\n| ~~`api.getDisplayedRows()`~~ | **shipped** | āœ“ |\r\n| ~~Client-side tree data (hierarchical rows)~~ | **shipped** - `treeData` nests by parent id, `flattenTreeData` converts nested children; treegrid role + arrow-key expand; demo `426-tree-data` | āœ“ |\r\n| ~~Built-in row dragging~~ | **shipped** - `rowDragManaged` reorders in-grid and moves rows **grid-to-grid** via a shared `rowDragGroup`; `onRowDragEnd` on the receiver; demos `105-row-reorder` (custom) + `180-row-dragging` (managed) | āœ“ |\r\n\r\n## Cells\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~Built-in tooltip API on `ColumnDef`~~ | **shipped** - `tooltip`; demo `85-tooltips-and-notes` | āœ“ |\r\n| ~~Formula language / formula editor~~ | **shipped** - in-grid engine (demo `83-spreadsheet-formulas`), HyperFormula adapter (demo `173-hyperformula`), xlsx formulas (`101`, `119`) | āœ“ |\r\n| ~~Find-in-grid~~ | **shipped** - Ctrl+F; demo `87-find-in-grid` | āœ“ |\r\n| ~~Notes~~ | **shipped** - `notes` prop + cell comments; demos `85-tooltips-and-notes`, `91-cell-comments` | āœ“ |\r\n| ~~Built-in cell flash / animated change highlight~~ | **shipped** - `cellFlash` on `ColumnDef` | āœ“ |\r\n\r\n## Export / Print\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~Excel / xlsx, PDF, CSV / TSV / HTML export, Print~~ | **shipped** in `@svgrid/enterprise` - demos `21`, `56`-`59`, `93`, `101`, `119`, `126`, `127` | āœ“ |\r\n\r\n## Filtering\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~`between` operator in the column menu~~ | **shipped** - demo `64-filter-between-operator` | āœ“ |\r\n| ~~Set filter (tree-list, async, Excel-mode)~~ | **shipped** - demo `111-set-filter-advanced` | āœ“ |\r\n| ~~Locale-aware text filtering~~ | **shipped** - demo `110-locale-aware-filter` | āœ“ |\r\n| ~~`clearAllFilters()` / `getFilters()`~~ | **shipped** | āœ“ |\r\n| ~~Floating filters (per-operator)~~ | **shipped** - filter row honours every operator per column with typed inputs + inline `between`; demo `179` | āœ“ |\r\n| ~~Multi-condition filter within one column (AND / OR)~~ | **shipped** - two conditions per column via the funnel or `api.setFilter`; demo `178` | āœ“ |\r\n\r\n## Editing\r\n\r\n| Gap | Status | Effort |\r\n| --- | ------ | ------ |\r\n| ~~`cellEditor` slot for custom inline editors~~ | **shipped** - demos `84-editor-types`, `66-custom-cell-editors` | āœ“ |\r\n| ~~Built-in select & rich-select editors~~ | **shipped** - `editorType: 'list' / 'rich-select'`; demo `84-editor-types` | āœ“ |\r\n| ~~Built-in large-text (textarea) editor~~ | **shipped** - demo `84-editor-types` | āœ“ |\r\n| ~~Per-column `validate()`~~ | **shipped** - demos `24-validation`, `103-async-validation` | āœ“ |\r\n| ~~Built-in undo / redo stack~~ | **shipped** - `api.undo()` / `redo()`; demo `86-undo-redo` | āœ“ |\r\n| ~~Batch / staged editing mode~~ | **shipped** - demo `88-staged-editing` | āœ“ |\r\n| ~~Per-column `valueParser`~~ | **shipped** - `valueParser` on `ColumnDef`; demo `175` | āœ“ |\r\n| ~~Programmatic `api.startEditing()` / `stopEditing()`~~ | **shipped** - demo `176` | āœ“ |\r\n| ~~Full-row editing mode~~ | **shipped** - `fullRowEditing`; demo `177` | āœ“ |\r\n| ~~Async / server-loaded editor option lists~~ | **shipped** - `editorOptions` may return a Promise (per column or per row), with a loading state, caching and `api.refreshEditorOptions()`; demo `428-async-editor-options` | āœ“ |\r\n\r\n## The real remaining gaps (short list)\r\n\r\nThe previous round shipped declarative col/row spanning, cell flash,\r\n`valueParser`, programmatic start/stop editing, full-row editing, multi-condition\r\nfilters, per-operator floating filters, and managed grid-to-grid row dragging -\r\nall with demos and docs. What is left is a short list of AG-Grid-Enterprise\r\nparity items, mostly UX affordances on top of engines that already exist:\r\n\r\nAudited against the code and the 171-demo catalog (four-way inventory, June 2026).\r\nThis list is deliberately short - most AG-Grid-Enterprise parity items already\r\nship (row-group panel `89`, status bar `144`, tool panel `146`, pivot + designer,\r\nserver-side row model `148`, export with images/styles `56`/`58`, charts,\r\nsparklines, collaboration). The genuine remaining gaps:\r\n\r\n| Gap | What exists today | Effort |\r\n| --- | ----------------- | ------ |\r\n| ~~**Multiple range selection** (Ctrl-drag additional cell ranges)~~ | **shipped** - Ctrl/Cmd+drag adds ranges; all highlight + copy together; `api.selectCells([...])` takes many; demo `118` | āœ“ |\r\n| ~~**Cell data-type inference** (`cellDataType`)~~ | **shipped** - `cellDataType` on `ColumnDef` + grid-level `inferColumnTypes` | āœ“ |\r\n| ~~**Merged-cell export to xlsx**~~ | **shipped** - `merges` option on `exportData` (single-sheet), lines up with `MergeSpec` | āœ“ |\r\n| ~~**Filters tool panel tab**~~ | **shipped** - Columns \\| Filters tabs in the tool panel (`146`), in sync with the column menu | āœ“ |\r\n| ~~**Copy with headers**~~ | **shipped** - `copyHeadersToClipboard` + `processCellForClipboard` hook | āœ“ |\r\n| ~~**Aligned grids**~~ | **shipped** - `alignedGridGroup` syncs horizontal scroll + column-resize widths; demo `182` | āœ“ |\r\n| ~~**Collapsible column groups**~~ | **shipped** - `columnGroupShow: 'open' \\| 'closed'` + `openByDefault`; demo `183` | āœ“ |\r\n| ~~**Column menu tabs** (General / Filter / Columns)~~ | **shipped** - tabbed column menu; demo any filterable grid | āœ“ |\r\n| ~~**External row-drag drop zones**~~ | **shipped** - `rowDropZone` action (drop rows onto any element); demo `184` | āœ“ |\r\n| ~~**Nested master/detail grids**~~ | **shipped** - `isDetailRow` + `renderDetailRow` hosting a child grid; demo `181` | āœ“ |\r\n\r\n### Still open (medium / large)\r\n\r\n| Gap | Note | Effort |\r\n| --- | ---- | ------ |\r\n| ~~**Multi Filter** (two conditions on one column)~~ | **shipped** - a column filter takes a second condition joined by AND / OR, in the menu and via `api.setFilter` | āœ“ |\r\n| **Custom filter / floating-filter component** slot | first-class pluggable filter | M |\r\n| **Custom tool panels** | panel is fixed Columns + Filters | M |\r\n| ~~**UI-string localisation** (`localeText`)~~ | **shipped** - `localeText` prop over `GridMessages`; every menu/panel/chrome string is overridable | āœ“ |\r\n| ~~**Row-grouping display modes** + group-level footers~~ | **shipped** - `groupDisplayMode: 'groupRows' \\| 'singleColumn' \\| 'multipleColumns'` plus `groupFooters` and `grandTotalRow`; demo `427-group-footers` | āœ“ |\r\n| ~~**In-grid pivot mode** (toggle on the main grid)~~ | **shipped** - `enablePivot()` registers the engine and the main grid pivots in place | āœ“ |\r\n| **Integrated-chart depth** (chart toolbar, cross-filtering) | 17 chart types + wizard + a \"Chart selected range\" context-menu item ship; the chart toolbar and click-to-filter loop do not | L |\r\n| **Server-side pivot / viewport row model** | SSRM ships sort/filter/group/infinite | L |\r\n\r\n## What's already there\r\n\r\nThe stable, built-in feature surface is large. Highlights: sorting (single +\r\nmulti), per-column filtering (menu + row + global) with a `between` range\r\noperator and set/tree/async filters, pagination, grouping + aggregation, tree\r\ndata, master/detail + full-width detail rows, row + column virtualization\r\n(100k+ and a 1M-row demo), cell-range selection + copy/paste + Excel-style fill\r\nhandle, inline editing with 14 editor types plus a custom `cellEditor` slot,\r\nundo/redo, staged editing, find-in-grid, notes + cell comments, tooltips,\r\nconditional formatting, sparklines, cell merging, column pinning/reorder/resize,\r\nrow pinning, a formula engine (+ HyperFormula adapter), server-side row model,\r\nExcel/PDF/CSV/HTML export + print (Enterprise), pivot + charts + AI (Enterprise),\r\nWAI-ARIA + keyboard nav, RTL, i18n, theming via `--sg-*` tokens, SSR, and a\r\nCSP-clean runtime.\r\n\r\n## How to contribute\r\n\r\n1. Pick a gap from **The real remaining gaps** above.\r\n2. Open an issue describing the API you'd want - names, types, the minimal change.\r\n3. If you can write the patch, do so, and keep tests with the change.\r\n"
3573
3594
  },
3574
3595
  {
3575
3596
  "slug": "help/mobile-card-view",
@@ -3647,7 +3668,7 @@ export const docs = [
3647
3668
  "slug": "help/rows/row-height",
3648
3669
  "path": "docs/help/rows/row-height.md",
3649
3670
  "title": "Row height",
3650
- "markdown": "# Row height\n\nRow height is a single integer in pixels.\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"540\"></div>\n\n```svelte\n<SvGrid {data} {columns} features={{}} rowHeight={36} />\n```\n\nThe default is 36 px. The virtualizer reads `rowHeight` once and uses it to\ncompute the visible window and total scroll height.\n\n## Density\n\nFor a density toggle, change `rowHeight` and a matching CSS custom property:\n\n```svelte\n<script lang=\"ts\">\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\n const px = $derived(density === 'compact' ? 28 : density === 'comfortable' ? 48 : 36)\n</script>\n\n<div style:--sg-row-height=\"{px}px\">\n <SvGrid {data} {columns} features={{}} rowHeight={px} />\n</div>\n```\n\nThe example gallery's\n[demos/10-custom-cells-and-themes.svelte](../../../examples/src/demos/10-custom-cells-and-themes.svelte)\nshows the density toggle in full.\n\n## Variable row height\n\nThe built-in virtualizer assumes uniform row height. **Variable height is not\nsupported by `<SvGrid>` directly**.\n\nIf you absolutely need it, use the lower-level headless virtualizer and roll\nyour own row layout:\n\n```ts\nimport { createSvelteVirtualizer } from '@svgrid/grid'\n\nconst virtualizer = createSvelteVirtualizer({\n count: () => rows.length,\n getScrollElement: () => scrollEl,\n estimateSize: (index) => rows[index]!.tall ? 80 : 36,\n overscan: 6,\n})\n```\n\nSee [`packages/grid/src/virtualization/`](../../../packages/grid/src/virtualization/).\n\n## Header height\n\nHeader height is independent of row height. See\n[Column headers](../columns/column-headers.md) for how to size it.\n\n## Row-number column width\n\nWhen `showRowNumbers={true}`, the leading row-number column defaults\nto **56 px**, which fits up to `99,999`. For larger datasets, bump\nthe width via `rowNumberWidth`:\n\n```svelte\n<!-- One million rows: \"1,000,000\" needs ~ 92 px to stay fully visible -->\n<SvGrid\n {data}\n {columns}\n features={{}}\n showRowNumbers={true}\n rowNumberWidth={92}\n rowHeight={18}\n virtualization={true}\n containerHeight=\"100%\"\n/>\n```\n\nRule of thumb: budget ~ 8 px per digit plus 14 px of padding. So:\n\n| Row count | Largest number | Suggested `rowNumberWidth` |\n|--------------|----------------|----------------------------|\n| < 1 000 | \"999\" | `40` |\n| < 100 000 | \"99,999\" | `56` (default) |\n| < 10 000 000 | \"9,999,999\" | `92` |\n\nDemo 78 (\"1 million rows\") uses 92 px so the millionth row's index\nstays legible at the bottom of the scroll.\n\n## See also\n\n- [Row pinning](./row-pinning.md)\n- [Styling rows](./styling-rows.md)\n- [Demo 78 - 1 million rows](../../../examples/src/demos/78-million-rows.svelte)\n"
3671
+ "markdown": "# Row height\r\n\r\nRow height is a single integer in pixels.\r\n<div data-docs-demo=\"10-custom-cells-and-themes\" data-height=\"540\"></div>\r\n\r\n```svelte\r\n<SvGrid {data} {columns} features={{}} rowHeight={36} />\r\n```\r\n\r\nThe default is 36 px. The virtualizer reads `rowHeight` once and uses it to\r\ncompute the visible window and total scroll height.\r\n\r\n## Density\r\n\r\nFor a density toggle, change `rowHeight` and a matching CSS custom property:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n let density = $state<'compact' | 'normal' | 'comfortable'>('normal')\r\n const px = $derived(density === 'compact' ? 28 : density === 'comfortable' ? 48 : 36)\r\n</script>\r\n\r\n<div style:--sg-row-height=\"{px}px\">\r\n <SvGrid {data} {columns} features={{}} rowHeight={px} />\r\n</div>\r\n```\r\n\r\nThe example gallery's\r\n[demos/10-custom-cells-and-themes.svelte](../../../examples/src/demos/10-custom-cells-and-themes.svelte)\r\nshows the density toggle in full.\r\n\r\n## Auto row height (size each row to its content)\r\n\r\n`autoRowHeight` lets cell text wrap and sizes every row to its tallest cell:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} autoRowHeight />\r\n```\r\n\r\nRows are measured after they render, so this works with virtualization. Before\r\na row has been measured the grid uses `rowHeight` (or 30) as its estimate, which\r\nkeeps the scrollbar stable as you scroll into rows for the first time:\r\n\r\n```svelte\r\n<!-- 44px is the starting guess; each row settles to its real height -->\r\n<SvGrid {data} {columns} autoRowHeight rowHeight={44} />\r\n```\r\n\r\nThings worth knowing:\r\n\r\n- It costs a measurement pass per row. With uniform content a fixed `rowHeight`\r\n is cheaper - reach for `autoRowHeight` when you have free text, notes, or\r\n wrapped addresses.\r\n- Passing a **function** `rowHeight` turns it off. You are already supplying\r\n per-row heights, so measuring would fight you.\r\n- Rows re-measure when their content reflows, e.g. after a column resize.\r\n- Measurements are dropped when the row set changes, so filtering or replacing\r\n `data` never sizes a new row by the old one's content.\r\n\r\n## Variable row height (you supply the numbers)\r\n\r\nPass a function to size rows yourself, without measuring:\r\n\r\n```svelte\r\n<SvGrid {data} {columns} rowHeight={(i) => (data[i].tall ? 80 : 36)} />\r\n```\r\n\r\nThe virtualizer handles the variable-size case natively (cumulative offsets), so\r\nscrolling and the total height stay correct. The same engine is available\r\nheadless if you are building your own row layout:\r\n\r\n```ts\r\nimport { createSvelteVirtualizer } from '@svgrid/grid'\r\n\r\nconst virtualizer = createSvelteVirtualizer({\r\n count: () => rows.length,\r\n getScrollElement: () => scrollEl,\r\n estimateSize: (index) => rows[index]!.tall ? 80 : 36,\r\n overscan: 6,\r\n})\r\n```\r\n\r\nSee [`packages/grid/src/virtualization/`](../../../packages/grid/src/virtualization/).\r\n\r\n## Header height\r\n\r\nHeader height is independent of row height. See\r\n[Column headers](../columns/column-headers.md) for how to size it.\r\n\r\n## Row-number column width\r\n\r\nWhen `showRowNumbers={true}`, the leading row-number column defaults\r\nto **56 px**, which fits up to `99,999`. For larger datasets, bump\r\nthe width via `rowNumberWidth`:\r\n\r\n```svelte\r\n<!-- One million rows: \"1,000,000\" needs ~ 92 px to stay fully visible -->\r\n<SvGrid\r\n {data}\r\n {columns}\r\n features={{}}\r\n showRowNumbers={true}\r\n rowNumberWidth={92}\r\n rowHeight={18}\r\n virtualization={true}\r\n containerHeight=\"100%\"\r\n/>\r\n```\r\n\r\nRule of thumb: budget ~ 8 px per digit plus 14 px of padding. So:\r\n\r\n| Row count | Largest number | Suggested `rowNumberWidth` |\r\n|--------------|----------------|----------------------------|\r\n| < 1 000 | \"999\" | `40` |\r\n| < 100 000 | \"99,999\" | `56` (default) |\r\n| < 10 000 000 | \"9,999,999\" | `92` |\r\n\r\nDemo 78 (\"1 million rows\") uses 92 px so the millionth row's index\r\nstays legible at the bottom of the scroll.\r\n\r\n## See also\r\n\r\n- [Row pinning](./row-pinning.md)\r\n- [Styling rows](./styling-rows.md)\r\n- [Demo 78 - 1 million rows](../../../examples/src/demos/78-million-rows.svelte)\r\n"
3651
3672
  },
3652
3673
  {
3653
3674
  "slug": "help/rows/row-pagination",
@@ -3665,13 +3686,13 @@ export const docs = [
3665
3686
  "slug": "help/rows/row-sorting",
3666
3687
  "path": "docs/help/rows/row-sorting.md",
3667
3688
  "title": "Row sorting",
3668
- "markdown": "# Row sorting\n\nSorting is a feature you opt into. Try it live - click any column\nheader to cycle `none → asc → desc → none`; shift-click adds the\ncolumn to the sort key list (multi-sort):\n\n<div data-docs-demo=\"02-sort-filter-paginate\" data-height=\"440\"></div>\n\n\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, type ColumnDef,\n } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n ]\n</script>\n\n<SvGrid {data} {columns} features={features} />\n```\n\nClicking a sortable header toggles `none → asc → desc → none`. Shift-click\nadds the column to the sort key list (multi-sort).\n\n## Sort functions\n\n`sortFns` exposes the built-in comparators:\n\n```ts\nimport { sortFns } from '@svgrid/grid'\n// sortFns.auto - lexical (default for unknown types)\n// sortFns.number - numeric, NaN-safe\n// sortFns.date - Date-parsed\n```\n\nThe grid picks the comparator based on the column's `editorType`:\n\n| editorType | comparator |\n| ---------- | ---------- |\n| `'number'` | `sortFns.number` |\n| `'date'` \\| `'datetime'` | `sortFns.date` |\n| anything else | `sortFns.auto` |\n\nIf your column has a non-trivial type, set `editorType` even if you do not\nwant inline editing - it is what tells sort and filter how to behave.\n\n## Programmatic sort\n\n```ts\napi.setSort('age', 'desc') // sort by age descending\napi.setSort('age', null) // clear sort on this column\napi.clearSort() // clear all sort\n```\n\n`setSort` replaces any existing sort with a single clause. For\nmulti-column sort, the user clicks the second column header with\n**Shift** held (the in-grid affordance). A multi-sort setter on the API\nis tracked in [Missing features](../missing-features.md).\n\nTo observe sort changes from outside the grid:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onSortingChange={(next) => (sorting = next)}\n/>\n```\n\n## Disable sort per column\n\nNot yet first-class - there is no `enableSorting: false` field. To\nprevent a column from being sortable, omit `rowSortingFeature` from\n`tableFeatures(...)`, or render a custom header via `header: () =>\nrenderSnippet(...)` that doesn't bind to the sort button.\n\n## Sorting + server-side data\n\nWhen sort happens on the backend, pair `externalSort={true}` with\n`onSortingChange` and round-trip the sort clauses through your\nfetcher. See the [`09-server-side` demo](../../../examples/src/demos/09-server-side.svelte).\n\n## See also\n\n- [Filter API](../filtering/filter-api.md)\n- [Server-side guide](../../getting-started.md#11-server-side-data)\n"
3689
+ "markdown": "# Row sorting\n\nSorting is a feature you opt into. Try it live - click any column\nheader to cycle `none → asc → desc → none`; shift-click adds the\ncolumn to the sort key list (multi-sort):\n\n<div data-docs-demo=\"02-sort-filter-paginate\" data-height=\"440\"></div>\n\n\n\n```svelte\n<script lang=\"ts\">\n import {\n SvGrid, tableFeatures, rowSortingFeature, type ColumnDef,\n } from '@svgrid/grid'\n\n const features = tableFeatures({ rowSortingFeature })\n\n const columns: ColumnDef<typeof features, Person>[] = [\n { field: 'firstName', header: 'First name', editorType: 'text' },\n { field: 'age', header: 'Age', editorType: 'number' },\n { field: 'joinedAt', header: 'Joined', editorType: 'date' },\n ]\n</script>\n\n<SvGrid {data} {columns} features={features} />\n```\n\nClicking a sortable header toggles `none → asc → desc → none`. Shift-click\nadds the column to the sort key list (multi-sort).\n\n## Sort functions\n\n`sortFns` exposes the built-in comparators:\n\n```ts\nimport { sortFns } from '@svgrid/grid'\n// sortFns.auto - lexical (default for unknown types)\n// sortFns.number - numeric, NaN-safe\n// sortFns.date - Date-parsed\n```\n\nThe grid picks the comparator based on the column's `editorType`:\n\n| editorType | comparator |\n| ---------- | ---------- |\n| `'number'` | `sortFns.number` |\n| `'date'` \\| `'datetime'` | `sortFns.date` |\n| anything else | `sortFns.auto` |\n\nIf your column has a non-trivial type, set `editorType` even if you do not\nwant inline editing - it is what tells sort and filter how to behave.\n\n## Programmatic sort\n\n```ts\napi.setSort('age', 'desc') // sort by age descending\napi.setSort('age', null) // clear sort on this column\napi.clearSort() // clear all sort\n```\n\n`setSort` replaces any existing sort with a single clause. For\nmulti-column sort, the user clicks the second column header with\n**Shift** held (the in-grid affordance). A multi-sort setter on the API\nis tracked in [Missing features](../missing-features.md).\n\nTo observe sort changes from outside the grid:\n\n```svelte\n<SvGrid\n data={rows}\n columns={columns}\n features={features}\n onSortingChange={(next) => (sorting = next)}\n/>\n```\n\n## Disable sort per column\n\nNot yet first-class - there is no `enableSorting: false` field. To\nprevent a column from being sortable, omit `rowSortingFeature` from\n`tableFeatures(...)`, or render a custom header via `header: () =>\nrenderSnippet(...)` that doesn't bind to the sort button.\n\n## Sorting + server-side data\n\nWhen sort happens on the backend, pair `externalSort={true}` with\n`onSortingChange` and round-trip the sort clauses through your\nfetcher. See the [`09-server-side` demo](../../../examples/src/demos/09-server-side.svelte).\n\n## See also\n\n- [Filter API](../filtering/filter-api.md)\n- [Server-side guide](../../getting-started.md#11-server-side-data)\n"
3669
3690
  },
3670
3691
  {
3671
3692
  "slug": "help/rows/row-spanning",
3672
3693
  "path": "docs/help/rows/row-spanning.md",
3673
3694
  "title": "Row spanning",
3674
- "markdown": "# Row spanning\n\n\"Row spanning\" lets one cell's content cover **multiple rows**, the way a\nmerged cell does in a spreadsheet.\n\n## Status\n\nRow spanning is **not yet built in**. The grid renders a strict 1-cell-per-\nrow-column grid; there is no `rowSpan` field on `ColumnDef` or\n`CellContext`.\n\n## Workarounds\n\n### 1. Group-by\n\nIf the spanning intent is \"show 'Engineering' once for every engineer\", use\ngrouping ([Row data](./row-data.md), [demos/07](../../../examples/src/demos/07-grouping-aggregation.svelte)) - the grid renders a single group row\nin place of repeated values.\n\n### 2. Cell renderer that suppresses repeats\n\nIf you simply want the *display* of repeated values to be blanked, render\nthe value only when it differs from the row above:\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n {\n field: 'department',\n header: 'Department',\n cell: (ctx) => {\n const data = ctx.table.getRowModel().rows\n const prev = data[ctx.row.index - 1]?.getCellValueByColumnId('department')\n return prev === ctx.getValue() ? '' : ctx.getValue() as string\n },\n },\n]\n```\n\nThis *looks* like a span but does not actually merge cells - keyboard\nnavigation still moves through every row.\n\n## Tracked at\n\n[Missing features](../missing-features.md) → \"row spanning / merged cells\".\n\n## See also\n\n- [Column spanning](../columns/column-spanning.md)\n- [Grouping](./row-data.md)\n"
3695
+ "markdown": "# Row spanning\n\n\"Row spanning\" lets one cell's content cover **multiple rows**, the way a\nmerged cell does in a spreadsheet.\n\n## Status\n\nRow spanning is **not yet built in**. The grid renders a strict 1-cell-per-\nrow-column grid; there is no `rowSpan` field on `ColumnDef` or\n`CellContext`.\n\n## Workarounds\n\n### 1. Group-by\n\nIf the spanning intent is \"show 'Engineering' once for every engineer\", use\ngrouping ([Row data](./row-data.md), [demos/07](../../../examples/src/demos/07-grouping-aggregation.svelte)) - the grid renders a single group row\nin place of repeated values.\n\n### 2. Cell renderer that suppresses repeats\n\nIf you simply want the *display* of repeated values to be blanked, render\nthe value only when it differs from the row above:\n\n```ts\nconst columns: ColumnDef<{}, Person>[] = [\n {\n field: 'department',\n header: 'Department',\n cell: (ctx) => {\n const data = ctx.table.getRowModel().rows\n const prev = data[ctx.row.index - 1]?.getCellValueByColumnId('department')\n return prev === ctx.getValue() ? '' : ctx.getValue() as string\n },\n },\n]\n```\n\nThis *looks* like a span but does not actually merge cells - keyboard\nnavigation still moves through every row.\n\n## Tracked at\n\n[Missing features](../missing-features.md) → \"row spanning / merged cells\".\n\n## See also\n\n- [Column spanning](../columns/column-spanning.md)\n- [Grouping](./row-data.md)\n"
3675
3696
  },
3676
3697
  {
3677
3698
  "slug": "help/rows/scheduler",
@@ -3689,13 +3710,19 @@ export const docs = [
3689
3710
  "slug": "help/rows/transactions",
3690
3711
  "path": "docs/help/rows/transactions.md",
3691
3712
  "title": "Transactions",
3692
- "markdown": "# Transactions\n\n`api.applyTransaction({ add, update, remove })` applies a batch of row\nmutations in a **single** data update - one re-render for the whole batch,\nnot one per row. It's the path to use for high-frequency / streaming feeds\n(WebSocket deltas, tick data) where calling `addRow` / `setCellValue` per row\nwould thrash.\n\n<div data-docs-demo=\"145-transaction-api\" data-height=\"480\"></div>\n\n```ts\nconst result = api.applyTransaction({\n add: [newOrder], // appended\n update: [{ ...order, price: nextPrice }], // matched by id\n remove: ['ORD-1001', staleRowRef], // by id OR row reference\n})\n// result -> { added: 1, updated: 1, removed: 2 }\n```\n\n## Matching\n\n- **`add`** - rows are appended to the data.\n- **`update`** - each row is matched to an existing row by **id**, then\n replaced. Set `getRowId` so the grid can compute ids; without it, updates\n can't be matched.\n- **`remove`** - accepts row **ids** (needs `getRowId`) and/or row **object\n references** (always works). Unknown ids are silently ignored.\n\nThe call returns the counts actually applied, so you can log or reconcile.\n\n## Why batch\n\nEach `applyTransaction` produces exactly one new data array and therefore one\nreactive update, regardless of how many rows changed. On a live feed that\nemits dozens of deltas per second, batching them per animation frame (or per\nWebSocket message) keeps the grid smooth where per-row calls would not.\n\n```ts\n// buffer deltas, flush once per frame\nlet buffer: Delta[] = []\nsocket.onmessage = (e) => { buffer.push(JSON.parse(e.data)); schedule() }\n\nfunction schedule() {\n requestAnimationFrame(() => {\n api.applyTransaction({\n add: buffer.filter((d) => d.type === 'add').map((d) => d.row),\n update: buffer.filter((d) => d.type === 'update').map((d) => d.row),\n remove: buffer.filter((d) => d.type === 'remove').map((d) => d.id),\n })\n buffer = []\n })\n}\n```\n\n## Notes\n\n- The grid owns its data after mount; read the current rows back with\n `api.getData()` (which reflects applied transactions).\n- Selection, expansion, and edit state keyed by `getRowId` survive a\n transaction - that's the point of a stable row id.\n\nSee the live [Transaction API](https://sv-grid.com/demos/145-transaction-api)\ndemo.\n"
3713
+ "markdown": "# Transactions\n\n`api.applyTransaction({ add, update, remove })` applies a batch of row\nmutations in a **single** data update - one re-render for the whole batch,\nnot one per row. It's the path to use for high-frequency / streaming feeds\n(WebSocket deltas, tick data) where calling `addRow` / `setCellValue` per row\nwould thrash.\n\n<div data-docs-demo=\"145-transaction-api\" data-height=\"480\"></div>\n\n```ts\nconst result = api.applyTransaction({\n add: [newOrder], // appended\n update: [{ ...order, price: nextPrice }], // matched by id\n remove: ['ORD-1001', staleRowRef], // by id OR row reference\n})\n// result -> { added: 1, updated: 1, removed: 2 }\n```\n\n## Matching\n\n- **`add`** - rows are appended to the data.\n- **`update`** - each row is matched to an existing row by **id**, then\n replaced. Set `getRowId` so the grid can compute ids; without it, updates\n can't be matched.\n- **`remove`** - accepts row **ids** (needs `getRowId`) and/or row **object\n references** (always works). Unknown ids are silently ignored.\n\nThe call returns the counts actually applied, so you can log or reconcile.\n\n## Why batch\n\nEach `applyTransaction` produces exactly one new data array and therefore one\nreactive update, regardless of how many rows changed. On a live feed that\nemits dozens of deltas per second, batching them per animation frame (or per\nWebSocket message) keeps the grid smooth where per-row calls would not.\n\n```ts\n// buffer deltas, flush once per frame\nlet buffer: Delta[] = []\nsocket.onmessage = (e) => { buffer.push(JSON.parse(e.data)); schedule() }\n\nfunction schedule() {\n requestAnimationFrame(() => {\n api.applyTransaction({\n add: buffer.filter((d) => d.type === 'add').map((d) => d.row),\n update: buffer.filter((d) => d.type === 'update').map((d) => d.row),\n remove: buffer.filter((d) => d.type === 'remove').map((d) => d.id),\n })\n buffer = []\n })\n}\n```\n\n## Notes\n\n- The grid owns its data after mount; read the current rows back with\n `api.getData()` (which reflects applied transactions).\n- Selection, expansion, and edit state keyed by `getRowId` survive a\n transaction - that's the point of a stable row id.\n\nSee the live [Transaction API](https://svgrid.com/demos/145-transaction-api/)\ndemo.\n"
3714
+ },
3715
+ {
3716
+ "slug": "help/rows/tree-data",
3717
+ "path": "docs/help/rows/tree-data.md",
3718
+ "title": "Tree data",
3719
+ "markdown": "# Tree data\r\n\r\nNest rows into an expandable hierarchy with the `treeData` prop.\r\n\r\n<div data-docs-demo=\"426-tree-data\" data-height=\"520\"></div>\r\n\r\nTree rows are **real data rows**. They keep their own cells, formatting,\r\nediting and selection, and only gain an expander plus indentation in the tree\r\ncolumn. That is the difference from row grouping, where the parent is a\r\nsynthetic full-width banner standing in for its children.\r\n\r\n## Flat data (parent id)\r\n\r\nThe model nests by parent id, so data that already carries one needs no\r\npreparation:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid } from '@svgrid/grid'\r\n\r\n const people = [\r\n { id: 1, managerId: null, name: 'Ada', title: 'CEO' },\r\n { id: 2, managerId: 1, name: 'Grace', title: 'VP Engineering' },\r\n { id: 3, managerId: 2, name: 'Alan', title: 'Principal Engineer' },\r\n ]\r\n</script>\r\n\r\n<SvGrid\r\n data={people}\r\n {columns}\r\n treeData={{ parentField: 'managerId', column: 'name' }}\r\n/>\r\n```\r\n\r\n| Option | Meaning |\r\n| --- | --- |\r\n| `parentField` | Field holding each row's parent id. Required. |\r\n| `idField` | Field holding the row's own id. Defaults to `'id'`. |\r\n| `column` | Column that carries the expander + indent. Defaults to the first visible column. |\r\n| `indentPx` | Indent per depth level, in px. Default `12`. |\r\n\r\n## Nested data (children arrays)\r\n\r\nThe grid only builds rows for the objects in `data`, so children nested inside\r\na parent are never rows. Flatten them first:\r\n\r\n```svelte\r\n<script lang=\"ts\">\r\n import { SvGrid, flattenTreeData } from '@svgrid/grid'\r\n\r\n const tree = [\r\n { id: 1, name: 'src', children: [\r\n { id: 2, name: 'components', children: [{ id: 3, name: 'Grid.svelte' }] },\r\n ] },\r\n ]\r\n\r\n // Stamps `__parentId` on every child and returns one flat list, children\r\n // directly after their parent.\r\n const rows = flattenTreeData(tree, { childrenField: 'children' })\r\n</script>\r\n\r\n<SvGrid data={rows} {columns} treeData={{ parentField: '__parentId', column: 'name' }} />\r\n```\r\n\r\n`flattenTreeData` takes `childrenField`, plus optional `idField` (default\r\n`'id'`) and `parentField` (default `'__parentId'`) if you want a different link\r\nfield.\r\n\r\n## Accessibility\r\n\r\nTurning on `treeData` puts the grid in the `treegrid` role. Every row gets\r\n`aria-level`, and expandable rows get `aria-expanded`. Keyboard:\r\n\r\n| Key | Action |\r\n| --- | --- |\r\n| <kbd>→</kbd> | Expand the focused row |\r\n| <kbd>←</kbd> | Collapse the focused row |\r\n| <kbd>Enter</kbd> / click | Toggle via the expander button |\r\n\r\nRows without children render a spacer where the chevron would be, so values\r\nstay aligned with their expandable siblings.\r\n\r\n## Edge cases\r\n\r\n- **Missing parent.** A row whose `parentField` points at an id that is not in\r\n the data becomes a root rather than disappearing. Losing rows silently is\r\n worse than a shallower tree - this matters when a filter removes a parent but\r\n keeps a child.\r\n- **Self-parenting.** A row that names itself as its parent is treated as a root.\r\n- **Cycles.** A loop in the parent chain is detected and stops the walk instead\r\n of recursing forever.\r\n- **Grouping.** `treeData` replaces row grouping - a row cannot be both a\r\n hierarchy node and bucketed under a group banner.\r\n\r\n## Server-side trees\r\n\r\nFor hierarchies too large to send at once, load children on demand with\r\n`serverGroup` and `ServerDataSource` instead - see\r\n[Server-side data](../server-side-data.md). The two share the same treegrid\r\nkeyboard and ARIA contract.\r\n\r\n## See also\r\n\r\n- [Demo #426 Tree data](https://svgrid.com/demos/426-tree-data/)\r\n- [Grouping & aggregation](../grouping-aggregation.md) - the row-bucketing shape\r\n- [Master / detail](./master-detail.md) - a rich panel under a row, rather than child rows\r\n"
3693
3720
  },
3694
3721
  {
3695
3722
  "slug": "help/rows/tree-rows",
3696
3723
  "path": "docs/help/rows/tree-rows.md",
3697
3724
  "title": "Tree rows (expand / collapse)",
3698
- "markdown": "# Tree rows (expand / collapse)\n\nSvGrid does not have a `treeData` prop. Tree-shaped data renders through\nthe same data + columns pipeline as any other grid, with the tree\nbehaviour living in *your* derived-state code. This is on purpose: a\ntree is just \"a flat list with a depth field and a collapsible\nsubtree\", and the headless engine doesn't need to know which.\n\n![Flat rows with a parentId and an expanded map deriving the visible rows, rendered as an indented tree with expand chevrons.](/docs-media/grid-tree-rows.svg)\n\nTry the org-chart pattern - click any chevron to expand a branch,\nor focus a name cell and press Right / Left / Enter:\n\n<div data-docs-demo=\"28-org-chart-tree\" data-height=\"520\"></div>\n\n\n## What it is\n\nA \"tree row\" is any row with a `depth: number`, a `childIds: string[]`\n(or equivalent), and a parent reference. Whether a row is currently\nvisible depends on its ancestors' expanded state.\n\n## The pattern\n\nThree pieces of state, the third one derived:\n\n```ts\nlet allRows = $state<Node[]>(/* every node, flat */)\nlet expanded = $state<Record<string, boolean>>({ root: true })\n\nconst visibleRows = $derived.by(() => {\n const out: Node[] = []\n const byId = new Map(allRows.map((n) => [n.id, n]))\n function walk(id: string) {\n const node = byId.get(id)\n if (!node) return\n out.push(node)\n if (expanded[id]) for (const cid of node.childIds) walk(cid)\n }\n for (const root of allRows.filter((n) => n.parentId === null)) walk(root.id)\n return out\n})\n```\n\nHand `visibleRows` to `<SvGrid data={visibleRows} ...>` like any\nother dataset. The grid stays unaware that the data is hierarchical.\n\n## The expand-chevron cell\n\nRender the chevron + indentation as part of a custom cell snippet on\nthe leftmost (or \"name\") column:\n\n```svelte\n{#snippet NameCell(props: { node: Node })}\n {@const canExpand = props.node.childIds.length > 0}\n {@const isOpen = !!expanded[props.node.id]}\n <span class=\"tree-name\" style=\"padding-left: {props.node.depth * 22}px\">\n {#if canExpand}\n <button\n type=\"button\"\n class={`tree-chev ${isOpen ? 'tree-chev-open' : ''}`}\n onclick={() => (expanded = { ...expanded, [props.node.id]: !isOpen })}\n aria-expanded={isOpen}\n aria-label={isOpen ? 'Collapse' : 'Expand'}\n >\n <svg viewBox=\"0 0 16 16\" width=\"10\" height=\"10\"\n fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <polyline points=\"5 3 11 8 5 13\" />\n </svg>\n </button>\n {/if}\n <span>{props.node.name}</span>\n </span>\n{/snippet}\n```\n\nThe corresponding CSS rotates the SVG instead of swapping a character,\nwhich animates smoothly:\n\n```css\n.tree-chev {\n transition: transform 160ms ease;\n}\n.tree-chev-open { transform: rotate(90deg); }\n```\n\n## Tree connector lines\n\nFor visual continuity between parents and children, draw guide lines\nin absolute position inside the name cell. One vertical guide per\nancestor depth, plus a short horizontal \"elbow\" into the current row:\n\n```svelte\n<span class=\"tree-name\" style=\"position: relative; padding-left: {4 + node.depth * 22}px\">\n {#each Array(node.depth) as _, i (i)}\n <span class=\"tree-guide\" style=\"left: {4 + i * 22 + 11}px\"></span>\n {/each}\n {#if node.depth > 0}\n <span class=\"tree-elbow\" style=\"left: {4 + (node.depth - 1) * 22 + 11}px\"></span>\n {/if}\n ...\n</span>\n```\n\n```css\n.tree-guide {\n position: absolute;\n top: 0; bottom: 0;\n border-left: 1px dashed rgba(148, 163, 184, 0.35);\n}\n.tree-elbow {\n position: absolute;\n top: 50%;\n width: 14px;\n border-top: 1px dashed rgba(148, 163, 184, 0.45);\n}\n```\n\n## Keyboard navigation\n\nThe grid's built-in arrow-key handling moves the active cell between\ncolumns. To get standard tree-grid keys (Right expands, Left collapses,\nEnter toggles), intercept at the window level **with a capture\nlistener** so your handler runs before the grid's:\n\n```ts\nlet activeCol = $state<string>('')\nlet activeRowIndex = $state<number>(0)\n\n$effect(() => {\n function onKey(e: KeyboardEvent) {\n if (activeCol !== 'name') return // not on the tree column\n const node = visibleRows[activeRowIndex]\n if (!node || node.childIds.length === 0) return // leaves don't toggle\n const isOpen = !!expanded[node.id]\n if (e.key === 'ArrowRight' && !isOpen) {\n e.preventDefault()\n expanded = { ...expanded, [node.id]: true }\n } else if (e.key === 'ArrowLeft' && isOpen) {\n e.preventDefault()\n expanded = { ...expanded, [node.id]: false }\n } else if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault()\n expanded = { ...expanded, [node.id]: !isOpen }\n }\n }\n window.addEventListener('keydown', onKey, true)\n return () => window.removeEventListener('keydown', onKey, true)\n})\n```\n\nWire SvGrid to track the active cell:\n\n```svelte\n<SvGrid\n data={visibleRows}\n columns={columns}\n onActiveCellChange={(args) => {\n activeCol = args.columnId\n activeRowIndex = args.rowIndex\n }}\n ...\n/>\n```\n\nThis pattern is non-invasive: regular arrow keys still move the active\ncell between non-name columns; tree keys only fire when the user is\nfocused on the tree column.\n\n## Roll-ups (computed values at non-leaf rows)\n\nWhen a parent's value is a roll-up of its children (headcount,\npercent-complete, cost), put the computation in a separate function\nthat runs after every leaf edit and writes the result back onto the\nparent rows:\n\n```ts\nfunction recompute(rows: Node[]): Node[] {\n const byId = new Map(rows.map((r) => [r.id, { ...r }]))\n // post-order DFS: deepest first so each parent already has updated children\n const ordered = [...byId.values()].sort((a, b) => b.depth - a.depth)\n for (const r of ordered) {\n if (r.childIds.length === 0) continue\n let sum = 0\n for (const cid of r.childIds) sum += byId.get(cid)!.subtotal\n r.subtotal = sum\n }\n return rows.map((r) => byId.get(r.id)!)\n}\n```\n\nHook it from `onCellValueChange`:\n\n```svelte\n<SvGrid\n ...\n onCellValueChange={(e) => {\n const next = allRows.slice()\n const ix = next.findIndex((r) => r.id === e.row.id)\n next[ix] = { ...next[ix], [e.columnId]: e.newValue }\n allRows = recompute(next)\n }}\n/>\n```\n\n## Lazy load on first expand\n\nFor trees that are too large to seed up front, fetch children only when\nthe user expands a node:\n\n```ts\nasync function toggle(id: string) {\n const isOpen = !!expanded[id]\n expanded = { ...expanded, [id]: !isOpen }\n if (isOpen) return // collapsing - no fetch needed\n\n const node = allNodes.find((n) => n.id === id)\n if (!node || !node.expandable || node.loadState === 'loaded') return\n\n allNodes = allNodes.map((n) => (n.id === id ? { ...n, loadState: 'loading' } : n))\n const children = await fetchChildren(id)\n allNodes = allNodes\n .map((n) => n.id === id ? { ...n, loadState: 'loaded', childIds: children.map((c) => c.id) } : n)\n .concat(children)\n}\n```\n\nRender a placeholder spinner row in `visibleRows` while `loadState === 'loading'`.\n\n## See also\n\n- [Row data](./row-data.md) - the underlying `data` prop and accessors.\n- [Row sorting](./row-sorting.md) - applies to tree rows too, but you\n control the order via the `visibleRows` derivation.\n- [Cell components](../cells/cell-components.md) - the custom-cell\n pattern used for the expand chevron.\n"
3725
+ "markdown": "# Tree rows (expand / collapse)\n\nSvGrid does not have a `treeData` prop. Tree-shaped data renders through\nthe same data + columns pipeline as any other grid, with the tree\nbehaviour living in *your* derived-state code. This is on purpose: a\ntree is just \"a flat list with a depth field and a collapsible\nsubtree\", and the headless engine doesn't need to know which.\n\n![Flat rows with a parentId and an expanded map deriving the visible rows, rendered as an indented tree with expand chevrons.](/docs-media/grid-tree-rows.svg)\n\nTry the org-chart pattern - click any chevron to expand a branch,\nor focus a name cell and press Right / Left / Enter:\n\n<div data-docs-demo=\"28-org-chart-tree\" data-height=\"520\"></div>\n\n\n## What it is\n\nA \"tree row\" is any row with a `depth: number`, a `childIds: string[]`\n(or equivalent), and a parent reference. Whether a row is currently\nvisible depends on its ancestors' expanded state.\n\n## The pattern\n\nThree pieces of state, the third one derived:\n\n```ts\nlet allRows = $state<Node[]>(/* every node, flat */)\nlet expanded = $state<Record<string, boolean>>({ root: true })\n\nconst visibleRows = $derived.by(() => {\n const out: Node[] = []\n const byId = new Map(allRows.map((n) => [n.id, n]))\n function walk(id: string) {\n const node = byId.get(id)\n if (!node) return\n out.push(node)\n if (expanded[id]) for (const cid of node.childIds) walk(cid)\n }\n for (const root of allRows.filter((n) => n.parentId === null)) walk(root.id)\n return out\n})\n```\n\nHand `visibleRows` to `<SvGrid data={visibleRows} ...>` like any\nother dataset. The grid stays unaware that the data is hierarchical.\n\n## The expand-chevron cell\n\nRender the chevron + indentation as part of a custom cell snippet on\nthe leftmost (or \"name\") column:\n\n```svelte\n{#snippet NameCell(props: { node: Node })}\n {@const canExpand = props.node.childIds.length > 0}\n {@const isOpen = !!expanded[props.node.id]}\n <span class=\"tree-name\" style=\"padding-left: {props.node.depth * 22}px\">\n {#if canExpand}\n <button\n type=\"button\"\n class={`tree-chev ${isOpen ? 'tree-chev-open' : ''}`}\n onclick={() => (expanded = { ...expanded, [props.node.id]: !isOpen })}\n aria-expanded={isOpen}\n aria-label={isOpen ? 'Collapse' : 'Expand'}\n >\n <svg viewBox=\"0 0 16 16\" width=\"10\" height=\"10\"\n fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.4\"\n stroke-linecap=\"round\" stroke-linejoin=\"round\">\n <polyline points=\"5 3 11 8 5 13\" />\n </svg>\n </button>\n {/if}\n <span>{props.node.name}</span>\n </span>\n{/snippet}\n```\n\nThe corresponding CSS rotates the SVG instead of swapping a character,\nwhich animates smoothly:\n\n```css\n.tree-chev {\n transition: transform 160ms ease;\n}\n.tree-chev-open { transform: rotate(90deg); }\n```\n\n## Tree connector lines\n\nFor visual continuity between parents and children, draw guide lines\nin absolute position inside the name cell. One vertical guide per\nancestor depth, plus a short horizontal \"elbow\" into the current row:\n\n```svelte\n<span class=\"tree-name\" style=\"position: relative; padding-left: {4 + node.depth * 22}px\">\n {#each Array(node.depth) as _, i (i)}\n <span class=\"tree-guide\" style=\"left: {4 + i * 22 + 11}px\"></span>\n {/each}\n {#if node.depth > 0}\n <span class=\"tree-elbow\" style=\"left: {4 + (node.depth - 1) * 22 + 11}px\"></span>\n {/if}\n ...\n</span>\n```\n\n```css\n.tree-guide {\n position: absolute;\n top: 0; bottom: 0;\n border-left: 1px dashed rgba(148, 163, 184, 0.35);\n}\n.tree-elbow {\n position: absolute;\n top: 50%;\n width: 14px;\n border-top: 1px dashed rgba(148, 163, 184, 0.45);\n}\n```\n\n## Keyboard navigation\n\nThe grid's built-in arrow-key handling moves the active cell between\ncolumns. To get standard tree-grid keys (Right expands, Left collapses,\nEnter toggles), intercept at the window level **with a capture\nlistener** so your handler runs before the grid's:\n\n```ts\nlet activeCol = $state<string>('')\nlet activeRowIndex = $state<number>(0)\n\n$effect(() => {\n function onKey(e: KeyboardEvent) {\n if (activeCol !== 'name') return // not on the tree column\n const node = visibleRows[activeRowIndex]\n if (!node || node.childIds.length === 0) return // leaves don't toggle\n const isOpen = !!expanded[node.id]\n if (e.key === 'ArrowRight' && !isOpen) {\n e.preventDefault()\n expanded = { ...expanded, [node.id]: true }\n } else if (e.key === 'ArrowLeft' && isOpen) {\n e.preventDefault()\n expanded = { ...expanded, [node.id]: false }\n } else if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault()\n expanded = { ...expanded, [node.id]: !isOpen }\n }\n }\n window.addEventListener('keydown', onKey, true)\n return () => window.removeEventListener('keydown', onKey, true)\n})\n```\n\nWire SvGrid to track the active cell:\n\n```svelte\n<SvGrid\n data={visibleRows}\n columns={columns}\n onActiveCellChange={(args) => {\n activeCol = args.columnId\n activeRowIndex = args.rowIndex\n }}\n ...\n/>\n```\n\nThis pattern is non-invasive: regular arrow keys still move the active\ncell between non-name columns; tree keys only fire when the user is\nfocused on the tree column.\n\n## Roll-ups (computed values at non-leaf rows)\n\nWhen a parent's value is a roll-up of its children (headcount,\npercent-complete, cost), put the computation in a separate function\nthat runs after every leaf edit and writes the result back onto the\nparent rows:\n\n```ts\nfunction recompute(rows: Node[]): Node[] {\n const byId = new Map(rows.map((r) => [r.id, { ...r }]))\n // post-order DFS: deepest first so each parent already has updated children\n const ordered = [...byId.values()].sort((a, b) => b.depth - a.depth)\n for (const r of ordered) {\n if (r.childIds.length === 0) continue\n let sum = 0\n for (const cid of r.childIds) sum += byId.get(cid)!.subtotal\n r.subtotal = sum\n }\n return rows.map((r) => byId.get(r.id)!)\n}\n```\n\nHook it from `onCellValueChange`:\n\n```svelte\n<SvGrid\n ...\n onCellValueChange={(e) => {\n const next = allRows.slice()\n const ix = next.findIndex((r) => r.id === e.row.id)\n next[ix] = { ...next[ix], [e.columnId]: e.newValue }\n allRows = recompute(next)\n }}\n/>\n```\n\n## Lazy load on first expand\n\nFor trees that are too large to seed up front, fetch children only when\nthe user expands a node:\n\n```ts\nasync function toggle(id: string) {\n const isOpen = !!expanded[id]\n expanded = { ...expanded, [id]: !isOpen }\n if (isOpen) return // collapsing - no fetch needed\n\n const node = allNodes.find((n) => n.id === id)\n if (!node || !node.expandable || node.loadState === 'loaded') return\n\n allNodes = allNodes.map((n) => (n.id === id ? { ...n, loadState: 'loading' } : n))\n const children = await fetchChildren(id)\n allNodes = allNodes\n .map((n) => n.id === id ? { ...n, loadState: 'loaded', childIds: children.map((c) => c.id) } : n)\n .concat(children)\n}\n```\n\nRender a placeholder spinner row in `visibleRows` while `loadState === 'loading'`.\n\n## See also\n\n- [Row data](./row-data.md) - the underlying `data` prop and accessors.\n- [Row sorting](./row-sorting.md) - applies to tree rows too, but you\n control the order via the `visibleRows` derivation.\n- [Cell components](../cells/cell-components.md) - the custom-cell\n pattern used for the expand chevron.\n"
3699
3726
  },
3700
3727
  {
3701
3728
  "slug": "help/saved-views",
@@ -3791,13 +3818,13 @@ export const docs = [
3791
3818
  "slug": "help/state/named-views",
3792
3819
  "path": "docs/help/state/named-views.md",
3793
3820
  "title": "Named views",
3794
- "markdown": "# Named views\n\nA \"view\" is a saved snapshot of the grid's state - sort, filters, column\norder/width/visibility, page, grouping. Named views let a user save the layout\nthey like and switch between them. SvGrid ships this as a small manager over\nthe grid's `getState()` / `setState()`, with pluggable storage.\n\n![save() captures a getState snapshot of sort, filter, columns, and page into a named view held in storage; load() reads it back and setState restores the grid to that view.](/docs-media/grid-named-views.svg)\n\n<div data-docs-demo=\"143-named-views\" data-height=\"480\"></div>\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, createNamedViews, localStorageViews, type SvGridApi } from '@svgrid/grid'\n\n let views: ReturnType<typeof createNamedViews> | null = null\n\n function onApiReady(api: SvGridApi<F, Row>) {\n views = createNamedViews(api, { storage: localStorageViews('my-grid-views') })\n }\n</script>\n\n<SvGrid {data} {columns} {features} onApiReady={onApiReady} />\n\n<button onclick={() => views?.save('Top earners')}>Save</button>\n<button onclick={() => views?.load('Top earners')}>Restore</button>\n```\n\n## The manager API\n\n`createNamedViews(api, { storage })` returns:\n\n| Method | Does |\n| ----------------- | ---------------------------------------------------------- |\n| `list()` | All saved views, oldest first. |\n| `save(name)` | Capture the current state under `name` (overwrites a dup). |\n| `load(name)` | Apply a saved view. Returns `false` if unknown. |\n| `remove(name)` | Delete a view. Returns `false` if unknown. |\n| `rename(a, b)` | Rename, unless `b` already exists. |\n| `has(name)` | Whether a view exists. |\n\nIt's pure and synchronous - all persistence goes through the storage adapter.\n\n## Storage adapters\n\n- **`localStorageViews(key)`** - persists per browser. SSR-safe (no-ops when\n `localStorage` is unavailable).\n- **`memoryViews()`** - in-memory only (the default if you pass no storage).\n- **Custom** - implement `ViewStorage` (`read()` / `write(views)`) to sync\n views to a server or a per-user account:\n\n```ts\nconst serverStorage: ViewStorage = {\n read: () => cachedViews,\n write: (views) => { cachedViews = views; fetch('/api/views', { method: 'PUT', body: JSON.stringify(views) }) },\n}\ncreateNamedViews(api, { storage: serverStorage })\n```\n\n## Notes\n\n- A view stores whatever `api.getState()` returns; restoring calls\n `api.setState(view.state)`, which applies only the keys present.\n- This is the building block; the demo wires a simple save-box + chips UI on\n top, but the manager is headless so you can render views however you like.\n\nSee the live [Named views](https://sv-grid.com/demos/143-named-views) demo.\n"
3821
+ "markdown": "# Named views\n\nA \"view\" is a saved snapshot of the grid's state - sort, filters, column\norder/width/visibility, page, grouping. Named views let a user save the layout\nthey like and switch between them. SvGrid ships this as a small manager over\nthe grid's `getState()` / `setState()`, with pluggable storage.\n\n![save() captures a getState snapshot of sort, filter, columns, and page into a named view held in storage; load() reads it back and setState restores the grid to that view.](/docs-media/grid-named-views.svg)\n\n<div data-docs-demo=\"143-named-views\" data-height=\"480\"></div>\n\n```svelte\n<script lang=\"ts\">\n import { SvGrid, createNamedViews, localStorageViews, type SvGridApi } from '@svgrid/grid'\n\n let views: ReturnType<typeof createNamedViews> | null = null\n\n function onApiReady(api: SvGridApi<F, Row>) {\n views = createNamedViews(api, { storage: localStorageViews('my-grid-views') })\n }\n</script>\n\n<SvGrid {data} {columns} {features} onApiReady={onApiReady} />\n\n<button onclick={() => views?.save('Top earners')}>Save</button>\n<button onclick={() => views?.load('Top earners')}>Restore</button>\n```\n\n## The manager API\n\n`createNamedViews(api, { storage })` returns:\n\n| Method | Does |\n| ----------------- | ---------------------------------------------------------- |\n| `list()` | All saved views, oldest first. |\n| `save(name)` | Capture the current state under `name` (overwrites a dup). |\n| `load(name)` | Apply a saved view. Returns `false` if unknown. |\n| `remove(name)` | Delete a view. Returns `false` if unknown. |\n| `rename(a, b)` | Rename, unless `b` already exists. |\n| `has(name)` | Whether a view exists. |\n\nIt's pure and synchronous - all persistence goes through the storage adapter.\n\n## Storage adapters\n\n- **`localStorageViews(key)`** - persists per browser. SSR-safe (no-ops when\n `localStorage` is unavailable).\n- **`memoryViews()`** - in-memory only (the default if you pass no storage).\n- **Custom** - implement `ViewStorage` (`read()` / `write(views)`) to sync\n views to a server or a per-user account:\n\n```ts\nconst serverStorage: ViewStorage = {\n read: () => cachedViews,\n write: (views) => { cachedViews = views; fetch('/api/views', { method: 'PUT', body: JSON.stringify(views) }) },\n}\ncreateNamedViews(api, { storage: serverStorage })\n```\n\n## Notes\n\n- A view stores whatever `api.getState()` returns; restoring calls\n `api.setState(view.state)`, which applies only the keys present.\n- This is the building block; the demo wires a simple save-box + chips UI on\n top, but the manager is headless so you can render views however you like.\n\nSee the live [Named views](https://svgrid.com/demos/143-named-views/) demo.\n"
3795
3822
  },
3796
3823
  {
3797
3824
  "slug": "help/status-bar",
3798
3825
  "path": "docs/help/status-bar.md",
3799
3826
  "title": "Status bar",
3800
- "markdown": "# Status bar\n\nThe status bar is the strip under the grid that shows live aggregates of the\n**selected cell range** - count, sum, average, min, max - the way Excel does at\nthe bottom-right when you select a block of numbers.\n\n```svelte\n<SvGrid {data} {columns} {features} enableCellSelection statusBar />\n```\n\nIt needs `enableCellSelection` (the bar aggregates the selected rectangle).\nDrag a range across numeric cells and the bar updates instantly.\n\n## Choosing aggregates\n\n`statusBar={true}` shows the default set (`count`, `sum`, `avg`, `min`, `max`).\nPass an object to pick which:\n\n```svelte\n<SvGrid enableCellSelection statusBar={{ aggregates: ['count', 'sum', 'avg'] }} />\n```\n\n| Aggregate | Meaning |\n| -------------- | ---------------------------------------------------- |\n| `count` | Cells in the selection (non-group). |\n| `numericCount` | How many of those hold a finite number. |\n| `sum` | Sum of the numeric cells. |\n| `avg` | Mean of the numeric cells. |\n| `min` / `max` | Smallest / largest numeric value. |\n\n## Behavior\n\n- The numeric aggregates (`sum`, `avg`, `min`, `max`) only appear when the\n selection actually contains numbers - selecting text cells shows just the\n count.\n- A single-cell selection shows nothing; the bar appears once a range of two\n or more cells is selected.\n- Edited values are respected: the aggregates read the displayed value, so an\n in-progress edit is reflected.\n- Group rows are skipped.\n\nSee the live [Status bar](https://sv-grid.com/demos/144-status-bar) demo.\n"
3827
+ "markdown": "# Status bar\n\nThe status bar is the strip under the grid that shows live aggregates of the\n**selected cell range** - count, sum, average, min, max - the way Excel does at\nthe bottom-right when you select a block of numbers.\n\n```svelte\n<SvGrid {data} {columns} {features} enableCellSelection statusBar />\n```\n\nIt needs `enableCellSelection` (the bar aggregates the selected rectangle).\nDrag a range across numeric cells and the bar updates instantly.\n\n## Choosing aggregates\n\n`statusBar={true}` shows the default set (`count`, `sum`, `avg`, `min`, `max`).\nPass an object to pick which:\n\n```svelte\n<SvGrid enableCellSelection statusBar={{ aggregates: ['count', 'sum', 'avg'] }} />\n```\n\n| Aggregate | Meaning |\n| -------------- | ---------------------------------------------------- |\n| `count` | Cells in the selection (non-group). |\n| `numericCount` | How many of those hold a finite number. |\n| `sum` | Sum of the numeric cells. |\n| `avg` | Mean of the numeric cells. |\n| `min` / `max` | Smallest / largest numeric value. |\n\n## Behavior\n\n- The numeric aggregates (`sum`, `avg`, `min`, `max`) only appear when the\n selection actually contains numbers - selecting text cells shows just the\n count.\n- A single-cell selection shows nothing; the bar appears once a range of two\n or more cells is selected.\n- Edited values are respected: the aggregates read the displayed value, so an\n in-progress edit is reflected.\n- Group rows are skipped.\n\nSee the live [Status bar](https://svgrid.com/demos/144-status-bar/) demo.\n"
3801
3828
  },
3802
3829
  {
3803
3830
  "slug": "help/tailwind",
@@ -3809,7 +3836,7 @@ export const docs = [
3809
3836
  "slug": "help/testing-and-quality",
3810
3837
  "path": "docs/help/testing-and-quality.md",
3811
3838
  "title": "Testing & Quality",
3812
- "markdown": "# Testing & Quality\n\nSvGrid ships with a comprehensive automated test suite. This page is the\nhonest accounting of what we test, what we don't, and where coverage\nstands today.\n\n## Headline numbers (v1.0)\n\n> **96.8% line coverage** on the testable surface\n> (`pnpm --filter @svgrid/grid test:lib`)\n\n| Metric | Coverage | Threshold |\n| ------ | -------- | --------- |\n| Lines | 92.20% | ≄ 90% |\n| Statements | 90.94% | ≄ 90% |\n| Branches | 82.21% | ≄ 75% |\n| Functions | 82.87% | ≄ 80% |\n\nRun the suite locally:\n\n```bash\npnpm test # alias for: pnpm --filter @svgrid/grid test:lib\npnpm test:types # svelte-check on every package\n```\n\nThe full coverage report lands in\n`packages/grid/coverage/index.html`.\n\n## What's measured\n\nThe **testable surface** is the headless engine, helpers, and pure logic\nfunctions:\n\n- `core.ts` (createSvGrid, row models, sortFns, filterFns) - ≄ 89% lines\n- `a11y.ts` (ARIA prop builders) - 100% lines\n- `keyboard.ts` (intent + next-cell math) - 100% lines\n- `cell-formatting.ts` (locale / currency / percent / date helpers) - 100% lines\n- `editors/cell-editors.ts` (parseEditorValue for every editor type) - 100% lines\n- `filtering/excel-filters.ts` (every Excel-style operator + edge cases) - 100% lines\n- `render-component.ts` (renderSnippet / renderComponent factories) - 100% lines\n- `subscribe.ts` (store subscription + shallow-compare) - 100% lines\n- `virtualization/*` - ≄ 86% lines\n\n## What's measured separately\n\nTwo files are tested via **behavioral mount tests** rather than line coverage\nbecause their branches depend on real browser layout (offsetWidth, scroll\ndimensions, ResizeObserver fires) that jsdom returns as zero:\n\n- **`SvGrid.svelte`** - the 4000-line render component. Covered by **60+\n behavioral mount tests** across\n [`svgrid.behavior.test.ts`](https://github.com/sv-grid/sv-grid/blob/main/packages/grid/src/svgrid.behavior.test.ts),\n [`svgrid.interaction.test.ts`](https://github.com/sv-grid/sv-grid/blob/main/packages/grid/src/svgrid.interaction.test.ts),\n and\n [`svgrid.api.test.ts`](https://github.com/sv-grid/sv-grid/blob/main/packages/grid/src/svgrid.api.test.ts).\n Each test mounts the real `<SvGrid />` in jsdom and exercises a specific\n feature: sort, filter, pagination, inline editing, cell selection,\n grouping, row selection, column add/remove, keyboard navigation, etc.\n- **`sv-grid-scrollbar.ts`** - a custom element that paints scrollbar\n glyphs from layout measurements. Its paint loop runs in a real browser;\n jsdom can't exercise it.\n\n## What's excluded\n\nThe coverage report excludes:\n\n- `SvGrid.svelte` (covered behaviorally - see above)\n- `FlexRender.svelte` (covered by `flex-render.test.ts` + every SvGrid mount)\n- `sv-grid-scrollbar.ts` (custom element)\n- `static-functions.ts` (pure re-exports)\n- `createGridState.svelte.ts` (downstream-adapter thin layer)\n- `test-fixtures/**`, `test-setup.ts`, `**/*.test.ts`, `**/*.d.ts`\n\nThe exclusion list is part of `packages/grid/vite.config.ts`\nand is documented inline with the reasoning for each entry.\n\n## Test files\n\n| File | Surface | Tests |\n| ---- | ------- | ----- |\n| `createGrid.test.ts` | Headless `createSvGrid` instance | Unit |\n| `svgrid.features.test.ts` | Row-model composition (core → filter → sort → group → expand → paginate) | Integration |\n| `svgrid.api.test.ts` | The imperative `SvGridApi` exposed via `onApiReady` | Mounted |\n| `svgrid.behavior.test.ts` | Wide behavior coverage: 30+ scenarios mounting the real component | Mounted |\n| `svgrid.interaction.test.ts` | Keyboard / pointer / scroll / edit events | Mounted |\n| `svgrid.wrapper.test.ts` | Source-string safety net | Static |\n| `svgrid.features.test.ts` | Feature composition + state hydration | Headless |\n| `core.coverage.test.ts` | Row / cell lazy getters, sortFns, filterFns, grouping | Unit |\n| `cell-formatting.test.ts` | Locale / currency / percent / date helpers | Unit |\n| `subscribe.test.ts` | Store subscription + shallowCompare | Unit |\n| `render-component.test.ts` | renderSnippet / renderComponent factories | Unit |\n| `flex-render.test.ts` | `<FlexRender />` discriminator (string / fn / config) | Mounted |\n| `editors/cell-editors.test.ts` | `parseEditorValue` per editor type | Unit |\n| `filtering/excel-filters.test.ts` | Every operator + every edge case | Unit |\n| `keyboard.test.ts` | `getKeyboardIntent` / `getNextActiveCell` | Pure unit |\n| `a11y.test.ts`, `a11y.contract.test.ts` | ARIA prop builders + contract | Pure unit |\n| `core.performance.test.ts` | Engine performance under large row counts | Benchmark |\n\nTotal: **1,191 tests** across **61 test files** (the table above lists the core suites; the full set also covers clipboard, selection, menus, editing, columns, charts, spreadsheet, server-side data, collaboration, and more).\n\n## Quality controls beyond unit tests\n\n- **TypeScript strict mode** across both packages. `pnpm test:types`\n must pass on every PR (currently 0 errors / 0 warnings).\n- **ESLint** at `pnpm lint`, with the Svelte plugin.\n- **Publint** at `pnpm --filter @svgrid/grid test:build` checks the\n published `exports` map.\n- **CSP-strict runtime**: no `eval`, no `new Function`, no inline scripts.\n Demo `16-csp-compliant` includes a runtime self-check.\n- **SSR snapshot**: demo `19-ssr` proves the grid renders meaningful HTML\n before hydration.\n- **Accessibility contracts**: `a11y.contract.test.ts` asserts that root,\n row, header, and cell prop builders produce a consistent ARIA tree.\n- **Mount-based behavioral tests** mount the real `<SvGrid />` in jsdom\n with polyfilled `ResizeObserver` / `IntersectionObserver` / `scrollIntoView`\n and exercise the imperative API end-to-end.\n\n## How to contribute a test\n\n1. Pick a behavior you want to lock down. Bias toward\n *\"user does X, grid does Y\"* over *\"function Z returns W\"*.\n2. If the behavior involves the rendered DOM, mount the component using\n the pattern in `svgrid.api.test.ts`:\n ```ts\n import { mount, unmount } from 'svelte'\n import SvGrid from './SvGrid.svelte'\n\n const target = document.createElement('div')\n document.body.appendChild(target)\n const app = mount(SvGrid, {\n target,\n props: { data, columns, features, onApiReady: (a) => { api = a } },\n })\n // exercise + assert\n unmount(app)\n ```\n3. If the behavior is pure (a row model, a sort comparator, an a11y prop\n builder), add to one of the existing unit-test files.\n4. Run `pnpm --filter @svgrid/grid exec vitest run <file>` to iterate\n fast.\n5. Open the PR; include the before/after coverage delta in the description.\n\n## CI\n\nThe deploy workflow (`.github/workflows/deploy-website.yml`) currently\nbuilds the library and the website. The next step is to add a\n**Test workflow** that runs `pnpm test` + `pnpm test:types` on every PR\nand posts the coverage delta as a comment.\n"
3839
+ "markdown": "# Testing & Quality\n\nSvGrid ships with a comprehensive automated test suite. This page is the\nhonest accounting of what we test, what we don't, and where coverage\nstands today.\n\n## Headline numbers (v1.0)\n\n> **96.8% line coverage** on the testable surface\n> (`pnpm --filter @svgrid/grid test:lib`)\n\n| Metric | Coverage | Threshold |\n| ------ | -------- | --------- |\n| Lines | 92.20% | ≄ 90% |\n| Statements | 90.94% | ≄ 90% |\n| Branches | 82.21% | ≄ 75% |\n| Functions | 82.87% | ≄ 80% |\n\nRun the suite locally:\n\n```bash\npnpm test # alias for: pnpm --filter @svgrid/grid test:lib\npnpm test:types # svelte-check on every package\n```\n\nThe full coverage report lands in\n`packages/grid/coverage/index.html`.\n\n## What's measured\n\nThe **testable surface** is the headless engine, helpers, and pure logic\nfunctions:\n\n- `core.ts` (createSvGrid, row models, sortFns, filterFns) - ≄ 89% lines\n- `a11y.ts` (ARIA prop builders) - 100% lines\n- `keyboard.ts` (intent + next-cell math) - 100% lines\n- `cell-formatting.ts` (locale / currency / percent / date helpers) - 100% lines\n- `editors/cell-editors.ts` (parseEditorValue for every editor type) - 100% lines\n- `filtering/excel-filters.ts` (every Excel-style operator + edge cases) - 100% lines\n- `render-component.ts` (renderSnippet / renderComponent factories) - 100% lines\n- `subscribe.ts` (store subscription + shallow-compare) - 100% lines\n- `virtualization/*` - ≄ 86% lines\n\n## What's measured separately\n\nTwo files are tested via **behavioral mount tests** rather than line coverage\nbecause their branches depend on real browser layout (offsetWidth, scroll\ndimensions, ResizeObserver fires) that jsdom returns as zero:\n\n- **`SvGrid.svelte`** - the 4000-line render component. Covered by **60+\n behavioral mount tests** across\n [`svgrid.behavior.test.ts`](https://github.com/sv-grid/sv-grid/blob/main/packages/grid/src/svgrid.behavior.test.ts),\n [`svgrid.interaction.test.ts`](https://github.com/sv-grid/sv-grid/blob/main/packages/grid/src/svgrid.interaction.test.ts),\n and\n [`svgrid.api.test.ts`](https://github.com/sv-grid/sv-grid/blob/main/packages/grid/src/svgrid.api.test.ts).\n Each test mounts the real `<SvGrid />` in jsdom and exercises a specific\n feature: sort, filter, pagination, inline editing, cell selection,\n grouping, row selection, column add/remove, keyboard navigation, etc.\n- **`sv-grid-scrollbar.ts`** - a custom element that paints scrollbar\n glyphs from layout measurements. Its paint loop runs in a real browser;\n jsdom can't exercise it.\n\n## What's excluded\n\nThe coverage report excludes:\n\n- `SvGrid.svelte` (covered behaviorally - see above)\n- `FlexRender.svelte` (covered by `flex-render.test.ts` + every SvGrid mount)\n- `sv-grid-scrollbar.ts` (custom element)\n- `static-functions.ts` (pure re-exports)\n- `createGridState.svelte.ts` (downstream-adapter thin layer)\n- `test-fixtures/**`, `test-setup.ts`, `**/*.test.ts`, `**/*.d.ts`\n\nThe exclusion list is part of `packages/grid/vite.config.ts`\nand is documented inline with the reasoning for each entry.\n\n## Test files\n\n| File | Surface | Tests |\n| ---- | ------- | ----- |\n| `createGrid.test.ts` | Headless `createSvGrid` instance | Unit |\n| `svgrid.features.test.ts` | Row-model composition (core → filter → sort → group → expand → paginate) | Integration |\n| `svgrid.api.test.ts` | The imperative `SvGridApi` exposed via `onApiReady` | Mounted |\n| `svgrid.behavior.test.ts` | Wide behavior coverage: 30+ scenarios mounting the real component | Mounted |\n| `svgrid.interaction.test.ts` | Keyboard / pointer / scroll / edit events | Mounted |\n| `svgrid.wrapper.test.ts` | Source-string safety net | Static |\n| `svgrid.features.test.ts` | Feature composition + state hydration | Headless |\n| `core.coverage.test.ts` | Row / cell lazy getters, sortFns, filterFns, grouping | Unit |\n| `cell-formatting.test.ts` | Locale / currency / percent / date helpers | Unit |\n| `subscribe.test.ts` | Store subscription + shallowCompare | Unit |\n| `render-component.test.ts` | renderSnippet / renderComponent factories | Unit |\n| `flex-render.test.ts` | `<FlexRender />` discriminator (string / fn / config) | Mounted |\n| `editors/cell-editors.test.ts` | `parseEditorValue` per editor type | Unit |\n| `filtering/excel-filters.test.ts` | Every operator + every edge case | Unit |\n| `keyboard.test.ts` | `getKeyboardIntent` / `getNextActiveCell` | Pure unit |\n| `a11y.test.ts`, `a11y.contract.test.ts` | ARIA prop builders + contract | Pure unit |\n| `core.performance.test.ts` | Engine performance under large row counts | Benchmark |\n\nTotal: **1,191 tests** across **61 test files** (the table above lists the core suites; the full set also covers clipboard, selection, menus, editing, columns, charts, spreadsheet, server-side data, collaboration, and more).\n\n## Quality controls beyond unit tests\n\n- **TypeScript strict mode** across both packages. `pnpm test:types`\n must pass on every PR (currently 0 errors / 0 warnings).\n- **ESLint** at `pnpm lint`, with the Svelte plugin.\n- **Publint** at `pnpm --filter @svgrid/grid test:build` checks the\n published `exports` map.\n- **CSP-strict runtime**: no `eval`, no `new Function`, no inline scripts.\n Demo `16-csp-compliant` includes a runtime self-check.\n- **SSR snapshot**: demo `19-ssr` proves the grid renders meaningful HTML\n before hydration.\n- **Accessibility contracts**: `a11y.contract.test.ts` asserts that root,\n row, header, and cell prop builders produce a consistent ARIA tree.\n- **Mount-based behavioral tests** mount the real `<SvGrid />` in jsdom\n with polyfilled `ResizeObserver` / `IntersectionObserver` / `scrollIntoView`\n and exercise the imperative API end-to-end.\n\n## How to contribute a test\n\n1. Pick a behavior you want to lock down. Bias toward\n *\"user does X, grid does Y\"* over *\"function Z returns W\"*.\n2. If the behavior involves the rendered DOM, mount the component using\n the pattern in `svgrid.api.test.ts`:\n ```ts\n import { mount, unmount } from 'svelte'\n import SvGrid from './SvGrid.svelte'\n\n const target = document.createElement('div')\n document.body.appendChild(target)\n const app = mount(SvGrid, {\n target,\n props: { data, columns, features, onApiReady: (a) => { api = a } },\n })\n // exercise + assert\n unmount(app)\n ```\n3. If the behavior is pure (a row model, a sort comparator, an a11y prop\n builder), add to one of the existing unit-test files.\n4. Run `pnpm --filter @svgrid/grid exec vitest run <file>` to iterate\n fast.\n5. Open the PR; include the before/after coverage delta in the description.\n\n## CI\n\nThe deploy workflow (`.github/workflows/deploy-website.yml`) currently\nbuilds the library and the website. The next step is to add a\n**Test workflow** that runs `pnpm test` + `pnpm test:types` on every PR\nand posts the coverage delta as a comment.\n"
3813
3840
  },
3814
3841
  {
3815
3842
  "slug": "help/testing",
@@ -4642,8 +4669,8 @@ export const docs = [
4642
4669
  {
4643
4670
  "slug": "recipes/testing-your-grid",
4644
4671
  "path": "docs/recipes/testing-your-grid.md",
4645
- "title": "Testing your grid",
4646
- "markdown": "# Testing your grid\n\nThree strategies for testing a grid in your app. Pick the strategy\nthat matches the question you're answering.\n\n## TL;DR\n\n| Strategy | Runner | Use for | Cost |\n| ------------------- | --------------------------------- | -------------------------------------------- | -------------- |\n| **Unit** | Vitest | Reducer / aggregator / pivot logic | < 1ms / test |\n| **Component** | @testing-library/svelte + jsdom | Mount-time behavior, ARIA, custom renderers | 5-25ms / test |\n| **E2E** | Playwright | Real browser, scrolling, downloads, screens | 0.5-2s / test |\n\nThe repo ships all three: vitest config in\n`packages/grid/vitest.config.ts`, component tests in\n`*.behavior.test.ts` files, Playwright specs in `examples/e2e/*.spec.ts`.\n\n## Strategy 1 - Vitest unit\n\nMock no DOM. Drive the engine directly via `createSvGrid` and assert\non the row model. Fastest, smallest blast radius - perfect for pure\nlogic (aggregators, custom filter fns, pivot models).\n\n```ts\nimport { describe, it, expect } from 'vitest'\nimport {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n} from '@svgrid/grid'\n\nconst features = tableFeatures({ rowSortingFeature })\n\ndescribe('row sorting', () => {\n it('sorts rows desc on api call', () => {\n type Row = { x: number }\n const data: Row[] = [{ x: 1 }, { x: 3 }, { x: 2 }]\n let sorting: Array<{ id: string; desc: boolean }> = []\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Row>(),\n sortedRowModel: createSortedRowModel<Row>(),\n },\n data,\n columns: [{ field: 'x' }],\n state: { sorting },\n onSortingChange: (u) => {\n sorting = typeof u === 'function' ? (u as (s: typeof sorting) => typeof sorting)(sorting) : u\n },\n enableSorting: true,\n } as never)\n\n table.setSorting([{ id: 'x', desc: true }])\n const rows = table.getRowModel().rows.map((r) => (r.original as Row).x)\n expect(rows).toEqual([3, 2, 1])\n })\n})\n```\n\nNotes:\n- `_rowModels` must be passed explicitly when bypassing `<SvGrid>` -\n the component wires them for you, the headless path doesn't.\n\n## Strategy 2 - Component (testing-library)\n\nMount the real `<SvGrid>` component into jsdom. Assert on accessible\nnames, roles, `aria-sort`, etc. This is where you catch\nregressions in custom cell renderers and headers.\n\n```ts\nimport { describe, it, expect } from 'vitest'\nimport { render, screen, fireEvent } from '@testing-library/svelte'\nimport { SvGrid, tableFeatures, rowSortingFeature } from '@svgrid/grid'\n\nconst features = tableFeatures({ rowSortingFeature })\n\ndescribe('<SvGrid> header', () => {\n it('toggles aria-sort on column-header click', async () => {\n const props = {\n data: [{ x: 1 }, { x: 3 }, { x: 2 }],\n columns: [{ field: 'x', header: 'X' }],\n features,\n }\n render(SvGrid, props)\n const header = screen.getByRole('columnheader', { name: /X/ })\n expect(header).toHaveAttribute('aria-sort', 'none')\n await fireEvent.click(header)\n expect(header).toHaveAttribute('aria-sort', 'ascending')\n })\n})\n```\n\nNotes:\n- jsdom doesn't implement `ResizeObserver` or layout - so virtualisation\n numbers aren't realistic here. Component tests are about *behavior*,\n not perf.\n- For ARIA assertions, lean on `@testing-library/jest-dom` matchers.\n\n## Strategy 3 - Playwright E2E\n\nDrive a real Chromium / WebKit / Firefox. Cover the things only a real\nbrowser does correctly: scrolling, downloads, screenshots, multi-tab.\n\n```ts\nimport { test, expect } from '@playwright/test'\n\ntest('large dataset paints the last row after scroll', async ({ page }) => {\n await page.goto('/demos/06-large-dataset')\n await expect(page.getByRole('grid')).toBeVisible()\n await page.locator('.sv-grid-body').evaluate((el) => {\n (el as HTMLElement).scrollTop = (el as HTMLElement).scrollHeight\n })\n await expect(page.getByText('Row 99999')).toBeVisible()\n await page.screenshot({ path: 'large-dataset-bottom.png' })\n})\n\ntest('export button downloads xlsx', async ({ page }) => {\n await page.goto('/demos/21-export-and-print')\n const [download] = await Promise.all([\n page.waitForEvent('download'),\n page.getByRole('button', { name: /Export Excel/ }).click(),\n ])\n expect(download.suggestedFilename()).toContain('.xlsx')\n})\n```\n\nNotes:\n- Run `npx playwright install` once to pull the browsers.\n- Use trace viewer (`--trace on`) when a test flakes - usually it's a\n missing `await page.waitForLoadState('networkidle')`.\n\n## Picking by question\n\n| Question | Strategy |\n| ----------------------------------------------------------------- | ---------- |\n| Does my custom `cell:` function return the right value? | Unit |\n| Does my `createPivotModel` config produce the right subtotals? | Unit |\n| Does clicking the header set `aria-sort`? | Component |\n| Does my snippet render the badge for `status === 'overdue'`? | Component |\n| Does scroll-to-bottom paint the last row of 100k? | E2E |\n| Does Export download an actual `.xlsx`? | E2E |\n| Does the grid still look right on a real laptop in Safari? | E2E |\n\n## See also\n\n- [`packages/grid/vitest.config.ts`](https://github.com/sv-grid/sv-grid/blob/main/packages/grid/vitest.config.ts) - the live config\n- [`*.behavior.test.ts` files](https://github.com/sv-grid/sv-grid/tree/main/packages/grid/src) - component-test examples\n- [`examples/e2e/`](https://github.com/sv-grid/sv-grid/tree/main/examples/e2e) - Playwright specs\n- [Architecture overview](../help/architecture.md) - what's headless vs what's rendered\n"
4672
+ "title": "Choosing a grid testing strategy",
4673
+ "markdown": "# Choosing a grid testing strategy\n\nThree strategies for testing a grid in your app. Pick the strategy\nthat matches the question you're answering.\n\n## TL;DR\n\n| Strategy | Runner | Use for | Cost |\n| ------------------- | --------------------------------- | -------------------------------------------- | -------------- |\n| **Unit** | Vitest | Reducer / aggregator / pivot logic | < 1ms / test |\n| **Component** | @testing-library/svelte + jsdom | Mount-time behavior, ARIA, custom renderers | 5-25ms / test |\n| **E2E** | Playwright | Real browser, scrolling, downloads, screens | 0.5-2s / test |\n\nThe repo ships all three: vitest config in\n`packages/grid/vitest.config.ts`, component tests in\n`*.behavior.test.ts` files, Playwright specs in `examples/e2e/*.spec.ts`.\n\n## Strategy 1 - Vitest unit\n\nMock no DOM. Drive the engine directly via `createSvGrid` and assert\non the row model. Fastest, smallest blast radius - perfect for pure\nlogic (aggregators, custom filter fns, pivot models).\n\n```ts\nimport { describe, it, expect } from 'vitest'\nimport {\n createSvGrid,\n createCoreRowModel,\n createSortedRowModel,\n tableFeatures,\n rowSortingFeature,\n} from '@svgrid/grid'\n\nconst features = tableFeatures({ rowSortingFeature })\n\ndescribe('row sorting', () => {\n it('sorts rows desc on api call', () => {\n type Row = { x: number }\n const data: Row[] = [{ x: 1 }, { x: 3 }, { x: 2 }]\n let sorting: Array<{ id: string; desc: boolean }> = []\n\n const table = createSvGrid({\n _features: features,\n _rowModels: {\n coreRowModel: createCoreRowModel<Row>(),\n sortedRowModel: createSortedRowModel<Row>(),\n },\n data,\n columns: [{ field: 'x' }],\n state: { sorting },\n onSortingChange: (u) => {\n sorting = typeof u === 'function' ? (u as (s: typeof sorting) => typeof sorting)(sorting) : u\n },\n enableSorting: true,\n } as never)\n\n table.setSorting([{ id: 'x', desc: true }])\n const rows = table.getRowModel().rows.map((r) => (r.original as Row).x)\n expect(rows).toEqual([3, 2, 1])\n })\n})\n```\n\nNotes:\n- `_rowModels` must be passed explicitly when bypassing `<SvGrid>` -\n the component wires them for you, the headless path doesn't.\n\n## Strategy 2 - Component (testing-library)\n\nMount the real `<SvGrid>` component into jsdom. Assert on accessible\nnames, roles, `aria-sort`, etc. This is where you catch\nregressions in custom cell renderers and headers.\n\n```ts\nimport { describe, it, expect } from 'vitest'\nimport { render, screen, fireEvent } from '@testing-library/svelte'\nimport { SvGrid, tableFeatures, rowSortingFeature } from '@svgrid/grid'\n\nconst features = tableFeatures({ rowSortingFeature })\n\ndescribe('<SvGrid> header', () => {\n it('toggles aria-sort on column-header click', async () => {\n const props = {\n data: [{ x: 1 }, { x: 3 }, { x: 2 }],\n columns: [{ field: 'x', header: 'X' }],\n features,\n }\n render(SvGrid, props)\n const header = screen.getByRole('columnheader', { name: /X/ })\n expect(header).toHaveAttribute('aria-sort', 'none')\n await fireEvent.click(header)\n expect(header).toHaveAttribute('aria-sort', 'ascending')\n })\n})\n```\n\nNotes:\n- jsdom doesn't implement `ResizeObserver` or layout - so virtualisation\n numbers aren't realistic here. Component tests are about *behavior*,\n not perf.\n- For ARIA assertions, lean on `@testing-library/jest-dom` matchers.\n\n## Strategy 3 - Playwright E2E\n\nDrive a real Chromium / WebKit / Firefox. Cover the things only a real\nbrowser does correctly: scrolling, downloads, screenshots, multi-tab.\n\n```ts\nimport { test, expect } from '@playwright/test'\n\ntest('large dataset paints the last row after scroll', async ({ page }) => {\n await page.goto('/demos/06-large-dataset')\n await expect(page.getByRole('grid')).toBeVisible()\n await page.locator('.sv-grid-body').evaluate((el) => {\n (el as HTMLElement).scrollTop = (el as HTMLElement).scrollHeight\n })\n await expect(page.getByText('Row 99999')).toBeVisible()\n await page.screenshot({ path: 'large-dataset-bottom.png' })\n})\n\ntest('export button downloads xlsx', async ({ page }) => {\n await page.goto('/demos/21-export-and-print')\n const [download] = await Promise.all([\n page.waitForEvent('download'),\n page.getByRole('button', { name: /Export Excel/ }).click(),\n ])\n expect(download.suggestedFilename()).toContain('.xlsx')\n})\n```\n\nNotes:\n- Run `npx playwright install` once to pull the browsers.\n- Use trace viewer (`--trace on`) when a test flakes - usually it's a\n missing `await page.waitForLoadState('networkidle')`.\n\n## Picking by question\n\n| Question | Strategy |\n| ----------------------------------------------------------------- | ---------- |\n| Does my custom `cell:` function return the right value? | Unit |\n| Does my `createPivotModel` config produce the right subtotals? | Unit |\n| Does clicking the header set `aria-sort`? | Component |\n| Does my snippet render the badge for `status === 'overdue'`? | Component |\n| Does scroll-to-bottom paint the last row of 100k? | E2E |\n| Does Export download an actual `.xlsx`? | E2E |\n| Does the grid still look right on a real laptop in Safari? | E2E |\n\n## See also\n\n- [`packages/grid/vitest.config.ts`](https://github.com/sv-grid/sv-grid/blob/main/packages/grid/vitest.config.ts) - the live config\n- [`*.behavior.test.ts` files](https://github.com/sv-grid/sv-grid/tree/main/packages/grid/src) - component-test examples\n- [`examples/e2e/`](https://github.com/sv-grid/sv-grid/tree/main/examples/e2e) - Playwright specs\n- [Architecture overview](../help/architecture.md) - what's headless vs what's rendered\n"
4647
4674
  },
4648
4675
  {
4649
4676
  "slug": "recipes/theme-matched-export",
@@ -4781,7 +4808,7 @@ export const docs = [
4781
4808
  "slug": "why-headless",
4782
4809
  "path": "docs/why-headless.md",
4783
4810
  "title": "Why headless?",
4784
- "markdown": "# Why headless?\n\nSvGrid is **headless at the core**, with a fully-styled Svelte component\nshipped on top. That two-layer split is deliberate, and worth\nunderstanding before you reach for either.\n\n## What \"headless\" actually means here\n\nThe core - `createSvGrid` from `@svgrid/grid/core` - knows about\nrows, columns, sorting, filtering, grouping, pagination, expansion, and\nselection. It does **not** know about pixels, DOM, ARIA, or CSS. It is\na state machine over your data that you query and mutate from Svelte.\n\nThe component - `<SvGrid>` - is one (opinionated) way to render that\nstate machine into a `<table>`. It is itself written against the\nheadless core, so the same hooks are available to you if you want to\nwrite your own renderer.\n\n```text\nā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”\n│ Your app │\nā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜\n │\n ā–¼\n ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”\n │ <SvGrid> (Svelte) │ ← default renderer, ARIA,\n │ FlexRender │ keyboard, drag handles,\n │ formatters, menus │ theme tokens\n ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜\n │\n ā–¼\n ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”\n │ createSvGrid() │ ← rows Ɨ cols Ɨ state\n │ row models │ sort / filter / page /\n │ features │ group / expand\n ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜\n```\n\n## What you get from headless\n\n**1. The renderer is replaceable.** Want a virtualised React grid? A\ncanvas-based renderer for 1 M rows? A read-only `<table>` for a printed\nreport? `createSvGrid` returns the same state machine for all of them.\nYou write the markup, you keep the headless brain.\n\n```ts\nimport { createSvGrid, createCoreRowModel, createSortedRowModel,\n tableFeatures, rowSortingFeature, sortFns } from '@svgrid/grid'\n\nconst grid = createSvGrid({\n _features: tableFeatures({ rowSortingFeature }),\n _rowModels: {\n coreRowModel: createCoreRowModel(),\n sortedRowModel: createSortedRowModel(sortFns),\n },\n columns,\n data,\n})\n\n// Your own render loop:\nfor (const row of grid.getRowModel().rows) {\n for (const cell of row.getAllCells()) drawCell(cell)\n}\n```\n\nHere is exactly that - the same headless brain, rendered as a plain\nhand-styled `<table>` instead of `<SvGrid>`. Sort and filter are the engine's;\nthe markup is the demo's:\n\n<div data-docs-demo=\"186-headless-table\" data-height=\"480\"></div>\n\n**2. Features are opt-in modules.** Monolithic grid libraries ship everything\nin one bundle. With SvGrid you only register what you use:\n\n```ts\nimport { tableFeatures, rowSortingFeature } from '@svgrid/grid'\n\n// no filtering, no grouping, no pagination - none of that code is\n// reachable from this grid instance\nconst features = tableFeatures({ rowSortingFeature })\n```\n\nThe features object is the contract the headless core checks for\noptional capabilities. Each feature ships a small chunk of state +\nhelpers; if it's not in `tableFeatures()`, the core never asks for it\nand Vite tree-shakes away the rest.\n\n**3. Tests are fast and DOM-free.** `createSvGrid` runs without a\nbrowser:\n\n```ts\nimport { createSvGrid, ... } from '@svgrid/grid'\n\ntest('sorts by salary descending', () => {\n const grid = createSvGrid({ ..., state: { sorting: [{ id: 'salary', desc: true }] } })\n const rows = grid.getRowModel().rows\n expect(rows[0]!.getValue('salary')).toBeGreaterThan(rows[1]!.getValue('salary'))\n})\n```\n\nNo JSDOM, no Playwright, no test renderer. The headless contract is the\nunit of test.\n\n**4. Server-side rendering is a non-feature.** Because the core has no\nDOM, you can call `grid.getRowModel().rows` inside a SvelteKit\n`+page.server.ts` and pre-bake the table HTML before it ever reaches\nthe browser. Demo [`19-ssr`](../examples/src/demos/19-ssr.svelte)\nwalks through that.\n\n**5. State is yours to own.** Sort clauses, filter predicates, expansion\nstate, selection state - all of it lives in a `store` you can serialise\nto a URL, sync to a query string, or restore from `localStorage`. The\ndefault `<SvGrid>` wires this up for you, but the wires are visible:\n\n```ts\n// Persist\nlocalStorage.setItem('grid', JSON.stringify(grid.getState()))\n\n// Restore\nconst saved = JSON.parse(localStorage.getItem('grid')!)\ngrid.store.setState((prev) => ({ ...prev, ...saved }))\n```\n\n## When the wrapper is the right tool anyway\n\nYou won't usually write a custom renderer. `<SvGrid>` is the default\nbecause the 80% case is \"I want a table, with sort and filter, that\nlooks correct\". The wrapper:\n\n- handles WAI-ARIA grid semantics, keyboard navigation, focus\n management, copy/paste, range selection;\n- wires virtualisation, column resize, fit-to-width, pinning, the\n filter menu, the column menu, the row-number column;\n- exposes a `SvGridApi` for data + columns + sort + filter +\n visibility mutations;\n- emits callbacks (`onSortingChange`, `onFiltersChange`,\n `onRowSelectionChange`, `onCellValueChange`) for parents that want to\n observe.\n\nReach for the headless core when:\n\n- you need a renderer the default cannot produce (canvas, mobile-only,\n Excel-export-only),\n- you're embedding the grid in an environment without a real DOM (SSR,\n static-site generators, PDF pipelines),\n- you want to drive multiple coordinated grids from one state store,\n- you're building a higher-level abstraction on top of SvGrid and want\n the headless API as your foundation.\n\n## The trade-off, named\n\nHeadless costs you a default theme. You can't `npm install` a\n\"complete-looking grid\" and have it match your app out of the box;\nevery grid library that promises that has to ship CSS and DOM\nassumptions you'll eventually fight.\n\nSvGrid splits the difference: the headless core is its own thing, and\n`<SvGrid>` is a *reference renderer* you can copy and modify. The\nshipped CSS uses `--sg-*` custom properties so you can re-theme it\nwithout forking. See [Tailwind integration](./help/tailwind.md) for a\nworked example.\n\n## See also\n\n- [Getting started](./getting-started.md) - the wrapper-first walkthrough\n- [Column definitions](./help/columns/column-definitions.md) - the contract the headless core enforces\n- [Filter API](./help/filtering/filter-api.md) - example of headless state surfaced through the wrapper\n- [`createSvGrid` source](../packages/grid/src/createGrid.svelte.ts)\n- Demo [`19-ssr`](../examples/src/demos/19-ssr.svelte) - SSR with the headless core\n"
4811
+ "markdown": "# Why headless?\n\nSvGrid is **headless at the core**, with a fully-styled Svelte component\nshipped on top. That two-layer split is deliberate, and worth\nunderstanding before you reach for either.\n\n## What \"headless\" actually means here\n\nThe core - `createSvGrid` from `@svgrid/grid/core` - knows about\nrows, columns, sorting, filtering, grouping, pagination, expansion, and\nselection. It does **not** know about pixels, DOM, ARIA, or CSS. It is\na state machine over your data that you query and mutate from Svelte.\n\nThe component - `<SvGrid>` - is one (opinionated) way to render that\nstate machine into a `<table>`. It is itself written against the\nheadless core, so the same hooks are available to you if you want to\nwrite your own renderer.\n\n```text\nā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”\n│ Your app │\nā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜\n │\n ā–¼\n ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”\n │ <SvGrid> (Svelte) │ ← default renderer, ARIA,\n │ FlexRender │ keyboard, drag handles,\n │ formatters, menus │ theme tokens\n ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”¬ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜\n │\n ā–¼\n ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”\n │ createSvGrid() │ ← rows Ɨ cols Ɨ state\n │ row models │ sort / filter / page /\n │ features │ group / expand\n ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜\n```\n\n## What you get from headless\n\n**1. The renderer is replaceable.** Want a virtualised React grid? A\ncanvas-based renderer for 1 M rows? A read-only `<table>` for a printed\nreport? `createSvGrid` returns the same state machine for all of them.\nYou write the markup, you keep the headless brain.\n\n```ts\nimport { createSvGrid, createCoreRowModel, createSortedRowModel,\n tableFeatures, rowSortingFeature, sortFns } from '@svgrid/grid'\n\nconst grid = createSvGrid({\n _features: tableFeatures({ rowSortingFeature }),\n _rowModels: {\n coreRowModel: createCoreRowModel(),\n sortedRowModel: createSortedRowModel(sortFns),\n },\n columns,\n data,\n})\n\n// Your own render loop:\nfor (const row of grid.getRowModel().rows) {\n for (const cell of row.getAllCells()) drawCell(cell)\n}\n```\n\nHere is exactly that - the same headless brain, rendered as a plain\nhand-styled `<table>` instead of `<SvGrid>`. Sort and filter are the engine's;\nthe markup is the demo's:\n\n<div data-docs-demo=\"186-headless-table\" data-height=\"480\"></div>\n\n**2. Features are opt-in modules.** Monolithic grid libraries ship everything\nin one bundle. With SvGrid you only register what you use:\n\n```ts\nimport { tableFeatures, rowSortingFeature } from '@svgrid/grid'\n\n// no filtering, no grouping, no pagination - none of that code is\n// reachable from this grid instance\nconst features = tableFeatures({ rowSortingFeature })\n```\n\nThe features object is the contract the headless core checks for\noptional capabilities. Each feature ships a small chunk of state +\nhelpers; if it's not in `tableFeatures()`, the core never asks for it\nand Vite tree-shakes away the rest.\n\n**3. Tests are fast and DOM-free.** `createSvGrid` runs without a\nbrowser:\n\n```ts\nimport { createSvGrid, ... } from '@svgrid/grid'\n\ntest('sorts by salary descending', () => {\n const grid = createSvGrid({ ..., state: { sorting: [{ id: 'salary', desc: true }] } })\n const rows = grid.getRowModel().rows\n expect(rows[0]!.getValue('salary')).toBeGreaterThan(rows[1]!.getValue('salary'))\n})\n```\n\nNo JSDOM, no Playwright, no test renderer. The headless contract is the\nunit of test.\n\n**4. Server-side rendering is a non-feature.** Because the core has no\nDOM, you can call `grid.getRowModel().rows` inside a SvelteKit\n`+page.server.ts` and pre-bake the table HTML before it ever reaches\nthe browser. Demo [`19-ssr`](../examples/src/demos/19-ssr.svelte)\nwalks through that.\n\n**5. State is yours to own.** Sort clauses, filter predicates, expansion\nstate, selection state - all of it lives in a `store` you can serialise\nto a URL, sync to a query string, or restore from `localStorage`. The\ndefault `<SvGrid>` wires this up for you, but the wires are visible:\n\n```ts\n// Persist\nlocalStorage.setItem('grid', JSON.stringify(grid.getState()))\n\n// Restore\nconst saved = JSON.parse(localStorage.getItem('grid')!)\ngrid.store.setState((prev) => ({ ...prev, ...saved }))\n```\n\n## When the wrapper is the right tool anyway\n\nYou won't usually write a custom renderer. `<SvGrid>` is the default\nbecause the 80% case is \"I want a table, with sort and filter, that\nlooks correct\". The wrapper:\n\n- handles WAI-ARIA grid semantics, keyboard navigation, focus\n management, copy/paste, range selection;\n- wires virtualisation, column resize, fit-to-width, pinning, the\n filter menu, the column menu, the row-number column;\n- exposes a `SvGridApi` for data + columns + sort + filter +\n visibility mutations;\n- emits callbacks (`onSortingChange`, `onFiltersChange`,\n `onRowSelectionChange`, `onCellValueChange`) for parents that want to\n observe.\n\nReach for the headless core when:\n\n- you need a renderer the default cannot produce (canvas, mobile-only,\n Excel-export-only),\n- you're embedding the grid in an environment without a real DOM (SSR,\n static-site generators, PDF pipelines),\n- you want to drive multiple coordinated grids from one state store,\n- you're building a higher-level abstraction on top of SvGrid and want\n the headless API as your foundation.\n\n## The trade-off, named\n\nHeadless costs you a default theme. You can't `npm install` a\n\"complete-looking grid\" and have it match your app out of the box;\nevery grid library that promises that has to ship CSS and DOM\nassumptions you'll eventually fight.\n\nSvGrid splits the difference: the headless core is its own thing, and\n`<SvGrid>` is a *reference renderer* you can copy and modify. The\nshipped CSS uses `--sg-*` custom properties so you can re-theme it\nwithout forking. See [Tailwind integration](./help/tailwind.md) for a\nworked example.\n\n## See also\n\n- [Getting started](./getting-started.md) - the wrapper-first walkthrough\n- [Column definitions](./help/columns/column-definitions.md) - the contract the headless core enforces\n- [Filter API](./help/filtering/filter-api.md) - example of headless state surfaced through the wrapper\n- [`createSvGrid` source](../packages/grid/src/createGrid.svelte.ts)\n- Demo [`19-ssr`](../examples/src/demos/19-ssr.svelte) - SSR with the headless core\n"
4785
4812
  }
4786
4813
  ];
4787
4814
  export const apiReference = {